package gitea import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "go.maximumdirect.net/releasekit/internal/forge" ) func TestBackendTagCommitUsesEscapedFullTagAndAuthentication(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got, want := r.RequestURI, "/api/v1/repos/owner/project/tags/v1.2.3%2Frc.1"; got != want { t.Errorf("request URI = %q, want %q", got, want) } if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { t.Errorf("authorization = %q, want %q", got, want) } _ = json.NewEncoder(w).Encode(map[string]any{"commit": map[string]string{"sha": "abcdef"}}) })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) commit, err := backend.TagCommit(context.Background(), "v1.2.3/rc.1") if err != nil || commit != "abcdef" { t.Fatalf("TagCommit() = %q, %v", commit, err) } } func TestBackendFindReleaseReturnsNeutralNotFound(t *testing.T) { server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) _, err := backend.FindRelease(context.Background(), "v1.2.3") var failure *forge.Failure if !errors.As(err, &failure) || failure.Kind != forge.FailureNotFound { t.Fatalf("FindRelease() error = %v, want neutral not-found failure", err) } var cause *Failure if !errors.As(err, &cause) || cause.StatusCode != http.StatusNotFound { t.Fatalf("FindRelease() error cause = %v, want Gitea 404 diagnostic", err) } } func TestBackendFindReleaseDoesNotRequireWriteResponseFields(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3", Name: "Release"}) })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) release, err := backend.FindRelease(context.Background(), "v1.2.3") if err != nil { t.Fatal(err) } if release.Tag != "v1.2.3" || release.Title != "Release" || release.Handle == nil { t.Fatalf("FindRelease() = %#v", release) } } func TestBackendAssetOperationsRequireObservedReleaseID(t *testing.T) { for _, tc := range []struct { name, body string valid bool }{ {"missing", `{"tag_name":"v1.2.3"}`, false}, {"zero", `{"id":0,"tag_name":"v1.2.3"}`, false}, {"negative", `{"id":-1,"tag_name":"v1.2.3"}`, false}, {"valid", `{"id":12,"tag_name":"v1.2.3"}`, true}, } { t.Run(tc.name, func(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/releases/tags/") { _, _ = io.WriteString(w, tc.body) return } requests++ if r.Method == http.MethodGet { _, _ = io.WriteString(w, `[]`) return } w.WriteHeader(http.StatusCreated) _, _ = io.WriteString(w, `{"id":1,"name":"file"}`) })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) release, err := backend.FindRelease(context.Background(), "v1.2.3") if err != nil { t.Fatal(err) } _, listErr := backend.ListAssets(context.Background(), release.Handle) evidence, uploadErr := backend.UploadAsset(context.Background(), release.Handle, "file", strings.NewReader("data")) if tc.valid { if listErr != nil || uploadErr != nil || requests != 2 { t.Fatalf("valid: list %v, upload %v, requests %d", listErr, uploadErr, requests) } return } for _, err := range []error{listErr, uploadErr} { var failure *forge.Failure if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed { t.Fatalf("failure = %v", err) } } if requests != 0 || evidence.Attempted || evidence.Disposition != forge.WriteNotAttempted { t.Fatalf("requests = %d, evidence = %#v", requests, evidence) } }) } backend := NewBackend(Destination{}, nil) _, listErr := backend.ListAssets(context.Background(), "wrong") evidence, uploadErr := backend.UploadAsset(context.Background(), "wrong", "file", strings.NewReader("data")) for _, err := range []error{listErr, uploadErr} { var failure *forge.Failure if !errors.As(err, &failure) || failure.Kind != forge.FailureInvalid { t.Fatalf("wrong handle failure = %v", err) } } if evidence.Attempted || evidence.Disposition != forge.WriteNotAttempted { t.Fatalf("wrong handle evidence = %#v", evidence) } } func TestBackendListsMetadataAndOpensCallerOwnedAssetStream(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { t.Errorf("authorization = %q, want %q", got, want) } switch r.URL.Path { case "/api/v1/repos/owner/project/releases/tags/v1.2.3": _ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "v1.2.3"}) case "/api/v1/repos/owner/project/releases/12/assets": _ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}}) case "/files/app": _, _ = io.WriteString(w, "data") default: t.Errorf("unexpected path %q", r.URL.Path) http.NotFound(w, r) } })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) release, err := backend.FindRelease(context.Background(), "v1.2.3") if err != nil { t.Fatal(err) } assets, err := backend.ListAssets(context.Background(), release.Handle) if err != nil { t.Fatal(err) } if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil { t.Fatalf("ListAssets() = %#v", assets) } stream, err := backend.OpenAsset(context.Background(), assets[0].Handle) if err != nil { t.Fatal(err) } content, readErr := io.ReadAll(stream) closeErr := stream.Close() if readErr != nil || closeErr != nil || string(content) != "data" { t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr) } } func TestBackendRejectsAssetHandleFromAnotherBackendType(t *testing.T) { backend := NewBackend(testDestination("http://example.test"), nil) _, err := backend.OpenAsset(context.Background(), strings.NewReader("asset")) var failure *forge.Failure if !errors.As(err, &failure) || failure.Kind != forge.FailureInvalid { t.Fatalf("OpenAsset() error = %v, want invalid handle", err) } } func TestBackendPreflightUsesAuthenticatedRepositoryReadsAndIgnoresCodePushPermission(t *testing.T) { var requests []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("method = %q, want GET", r.Method) } if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { t.Errorf("authorization = %q, want %q", got, want) } requests = append(requests, r.URL.Path) writeRepository := func() { _ = json.NewEncoder(w).Encode(map[string]any{ "id": 31, "name": "project", "full_name": "owner/project", "owner": map[string]string{"login": "owner"}, "permissions": map[string]bool{"admin": false, "push": false, "pull": true}, }) } switch r.URL.Path { case "/api/v1/repos/owner/project", "/api/v1/repositories/31": writeRepository() default: t.Errorf("unexpected path %q", r.URL.Path) http.NotFound(w, r) } })) defer server.Close() if err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()); err != nil { t.Fatal(err) } if len(requests) != 2 || requests[0] != "/api/v1/repos/owner/project" || requests[1] != "/api/v1/repositories/31" { t.Fatalf("preflight requests = %v, want both repository reads", requests) } } func TestBackendPreflightRequiresCredentialWithoutMakingRequests(t *testing.T) { var requests int server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ })) defer server.Close() destination := testDestination(server.URL) destination.Token = " \t" err := NewBackend(destination, server.Client()).Preflight(context.Background()) var failure *forge.PreflightFailure if !errors.As(err, &failure) || failure.Kind != forge.PreflightMissingCredential { t.Fatalf("Preflight() error = %v, want missing credential", err) } if requests != 0 { t.Fatalf("made %d requests without a credential", requests) } } func TestBackendPreflightDoesNotAcceptPublicLookupWithoutAuthenticatedAccess(t *testing.T) { var requests []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests = append(requests, r.URL.Path) if r.URL.Path == "/api/v1/repos/owner/project" { _ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "project", "full_name": "owner/project", "owner": map[string]string{"login": "owner"}}) return } http.Error(w, "token rejected", http.StatusUnauthorized) })) defer server.Close() err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()) var failure *forge.PreflightFailure if !errors.As(err, &failure) || failure.Kind != forge.PreflightInvalidCredential { t.Fatalf("Preflight() error = %v, want invalid credential", err) } if len(requests) != 2 || requests[1] != "/api/v1/repositories/31" { t.Fatalf("preflight requests = %v, want authenticated repository read", requests) } if strings.Contains(err.Error(), testDestination(server.URL).Token) { t.Fatalf("preflight error exposed the credential: %v", err) } } func TestBackendPreflightClassifiesDeniedAndHiddenRepositoriesAsInaccessible(t *testing.T) { for _, status := range []int{http.StatusForbidden, http.StatusNotFound} { t.Run(http.StatusText(status), func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "repository unavailable", status) })) defer server.Close() err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()) var failure *forge.PreflightFailure if !errors.As(err, &failure) || failure.Kind != forge.PreflightInaccessibleDestination { t.Fatalf("Preflight() error = %v, want inaccessible destination", err) } }) } } func TestBackendPreflightClassifiesUnverifiableResponsesAsUnavailable(t *testing.T) { for _, test := range []struct { name string respond func(http.ResponseWriter) }{ {name: "malformed JSON", respond: func(w http.ResponseWriter) { _, _ = io.WriteString(w, "{") }}, {name: "service failure", respond: func(w http.ResponseWriter) { http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) }}, {name: "wrong repository identity", respond: func(w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "other", "full_name": "owner/other", "owner": map[string]string{"login": "owner"}}) }}, } { t.Run(test.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { test.respond(w) })) defer server.Close() err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()) var failure *forge.PreflightFailure if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable { t.Fatalf("Preflight() error = %v, want unavailable", err) } }) } } func TestBackendPreflightPreservesCancellation(t *testing.T) { server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() ctx, cancel := context.WithCancel(context.Background()) cancel() err := NewBackend(testDestination(server.URL), server.Client()).Preflight(ctx) var failure *forge.PreflightFailure if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable || !errors.Is(err, context.Canceled) { t.Fatalf("Preflight() error = %v, want unavailable and context cancellation", err) } } func TestBackendCreateReleaseSendsRequestedFieldsAndAcceptsProtocolAcknowledgment(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases" { t.Errorf("request = %s %s", r.Method, r.URL.Path) } if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { t.Errorf("authorization = %q, want %q", got, want) } var request createReleaseRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Errorf("decode request: %v", err) } if request.TagName != "v1.2.3" || request.TargetCommitish != strings.Repeat("a", 40) || request.Name != "Release title" || request.Body != "# Release title\n\nComplete notes." || !request.Draft || !request.Prerelease { t.Errorf("release request = %#v", request) } _ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "server tag", Name: "server title", Body: "server body", Draft: true, Prerelease: true}) })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) release, evidence, err := backend.CreateRelease(context.Background(), forge.CreateReleaseRequest{ Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release title", Body: "# Release title\n\nComplete notes.", Draft: true, Prerelease: true, }) if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) { t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err) } if release.Handle == nil || release.Tag != "server tag" || release.Title != "server title" || release.Body != "server body" || !release.Draft || !release.Prerelease { t.Fatalf("CreateRelease() release = %#v", release) } } func TestBackendCreateReleaseMarksInvalidAcknowledgmentUncertain(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3"}) })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) _, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest()) var failure *forge.Failure if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) { t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err) } } func TestBackendCreateReleaseClassifiesConflictAndServerFailure(t *testing.T) { for _, test := range []struct { status int disposition forge.WriteDisposition }{ {status: http.StatusBadRequest, disposition: forge.WriteRejected}, {status: http.StatusConflict, disposition: forge.WriteConflict}, {status: http.StatusServiceUnavailable, disposition: forge.WriteUncertain}, } { t.Run(http.StatusText(test.status), func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) _, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest()) if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) { t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err) } }) } } func TestBackendUploadAssetBorrowsReaderAndValidatesAcknowledgment(t *testing.T) { var uploaded string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases/12/assets" || r.URL.Query().Get("name") != "app.bin" { t.Errorf("request = %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery) } if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want { t.Errorf("authorization = %q, want %q", got, want) } file, header, err := r.FormFile("attachment") if err != nil { t.Errorf("attachment: %v", err) } else { defer file.Close() content, readErr := io.ReadAll(file) if readErr != nil { t.Errorf("read attachment: %v", readErr) } if header.Filename != "app.bin" { t.Errorf("multipart filename = %q", header.Filename) } uploaded = string(content) } _ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin"}) })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) reader := &trackedReader{Reader: strings.NewReader("streamed bytes")} evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", reader) if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) { t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err) } if uploaded != "streamed bytes" || reader.closed { t.Fatalf("uploaded = %q, caller reader closed = %v", uploaded, reader.closed) } } func TestBackendUploadAssetMarksBadAcknowledgmentUncertain(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "different.bin"}) })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data")) var failure *forge.Failure if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) { t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err) } } func TestBackendUploadAssetClassifiesKnownRejectionAndConflict(t *testing.T) { for _, test := range []struct { status int disposition forge.WriteDisposition }{ {status: http.StatusBadRequest, disposition: forge.WriteRejected}, {status: http.StatusConflict, disposition: forge.WriteConflict}, {status: http.StatusInternalServerError, disposition: forge.WriteUncertain}, } { t.Run(http.StatusText(test.status), func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data")) if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) { t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err) } }) } } func TestBackendUploadAssetPreservesCancellationAsUncertain(t *testing.T) { server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() ctx, cancel := context.WithCancel(context.Background()) cancel() evidence, err := NewBackend(testDestination(server.URL), server.Client()).UploadAsset(ctx, releaseHandle{id: 12}, "app.bin", strings.NewReader("data")) if evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) || !errors.Is(err, context.Canceled) { t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err) } } func TestBackendUploadAssetWaitsForMultipartWriterAfterReaderFailure(t *testing.T) { readerErr := errors.New("asset reader failed") readDone := make(chan struct{}) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/project/releases/tags/v1.2.3" { _ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3"}) return } _, _ = io.Copy(io.Discard, r.Body) _ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin", Size: 0}) })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) release, err := backend.FindRelease(context.Background(), "v1.2.3") if err != nil { t.Fatalf("FindRelease() error = %v", err) } evidence, err := backend.UploadAsset(context.Background(), release.Handle, "app.bin", failingUploadReader{err: readerErr, done: readDone}) select { case <-readDone: default: t.Fatal("UploadAsset() returned before the multipart worker finished reading") } if !errors.Is(err, readerErr) { t.Fatalf("UploadAsset() error = %v, want reader cause %v", err, readerErr) } if !evidence.Attempted || evidence.Disposition != forge.WriteUncertain { t.Fatalf("UploadAsset() evidence = %#v, want uncertain attempted write", evidence) } } type failingUploadReader struct { err error done chan struct{} } func (reader failingUploadReader) Read([]byte) (int, error) { close(reader.done) return 0, reader.err } func validCreateReleaseRequest() forge.CreateReleaseRequest { return forge.CreateReleaseRequest{Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release", Body: "# Release"} } func testDestination(baseURL string) Destination { return Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: "fixture-secret-token"} } type trackedReader struct { io.Reader closed bool } func (reader *trackedReader) Close() error { reader.closed = true return nil }