package gitea import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" ) const testCommit = "0123456789012345678901234567890123456789" const testNote = "# Example v1.2.3\n\nRelease details.\n" const testToken = "fixture-secret-token" func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) { server, _ := newReleaseServer(t, releaseFixture{title: "Historical title"}, false) result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if err != nil { t.Fatal(err) } if result.Created || result.Release.Title != "Historical title" { t.Fatalf("result = %#v", result) } } func TestEnsureReleaseEscapesSlashContainingTag(t *testing.T) { tag := "integrations/ckconfig/v1.2.3" state := &fixtureState{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAuth(t, r) switch r.URL.EscapedPath() { case "/api/v1/repos/owner/project/tags/integrations%2Fckconfig%2Fv1.2.3": assertRequest(t, r, http.MethodGet, r.URL.Path) writeTag(t, w, testCommit) case "/api/v1/repos/owner/project/releases/tags/integrations%2Fckconfig%2Fv1.2.3": assertRequest(t, r, http.MethodGet, r.URL.Path) http.NotFound(w, r) case "/api/v1/repos/owner/project/releases": assertRequest(t, r, http.MethodPost, r.URL.Path) state.posts++ var payload createReleaseRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatal(err) } if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote { t.Fatalf("create payload = %#v", payload) } if err := json.NewEncoder(w).Encode(releaseResponse{ID: 7, HTMLURL: "https://example.test/release", TagName: tag, TargetCommitish: testCommit, Name: payload.Name, Body: payload.Body}); err != nil { t.Fatal(err) } default: t.Fatalf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath()) } })) defer server.Close() request := testRequest(server.URL) request.Tag = tag result, err := testClient(server).EnsureRelease(context.Background(), request) if err != nil || !result.Created || state.posts != 1 { t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts, err) } } func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) { server, _ := newReleaseServer(t, releaseFixture{}, true) result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if err != nil { t.Fatal(err) } if !result.Created || result.Release.Title != "Example v1.2.3" || result.Release.Body != testNote { t.Fatalf("result = %#v", result) } } func TestEnsureReleaseConflictDoesNotEditExistingPage(t *testing.T) { server, state := newReleaseServer(t, releaseFixture{title: "Keep this title", body: "Different body"}, false) _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if failureKind(t, err) != FailureConflict { t.Fatalf("error = %v", err) } if got := state.posts; got != 0 { t.Fatalf("release creates = %d, want 0", got) } } func TestEnsureReleaseUnauthorizedIsStructuredAndHidesToken(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") w.WriteHeader(http.StatusUnauthorized) })) defer server.Close() _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if failureKind(t, err) != FailureUnauthorized { t.Fatalf("error = %v", err) } if fmt.Sprint(err) == "" || contains(fmt.Sprint(err), testToken) { t.Fatalf("error exposes unexpected content: %v", err) } } func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) { posts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { posts++ } assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") _, _ = w.Write([]byte("{")) })) defer server.Close() _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if failureKind(t, err) != FailureMalformed || posts != 0 { t.Fatalf("error = %v, posts = %d", err, posts) } } func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) { posts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") if r.Method == http.MethodPost { posts++ } _, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7) })) defer server.Close() _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if failureKind(t, err) != FailureConflict || posts != 0 { t.Fatalf("error = %v, posts = %d", err, posts) } } func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) { posts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") if r.Method == http.MethodPost { posts++ } http.NotFound(w, r) })) defer server.Close() _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if failureKind(t, err) != FailureNotFound || posts != 0 { t.Fatalf("error = %v, posts = %d", err, posts) } } func TestEnsureReleaseHonorsContextCancellation(t *testing.T) { called := false server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) defer server.Close() ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL)) if !errors.Is(err, context.Canceled) || called { t.Fatalf("error = %v, server called = %t", err, called) } } func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) { created := false lookups := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAuth(t, r) switch r.URL.Path { case "/api/v1/repos/owner/project/tags/v1.2.3": assertRequest(t, r, http.MethodGet, r.URL.Path) writeTag(t, w, testCommit) case "/api/v1/repos/owner/project/releases/tags/v1.2.3": assertRequest(t, r, http.MethodGet, r.URL.Path) lookups++ if !created { http.NotFound(w, r) return } writeRelease(t, w, releaseFixture{title: "Example v1.2.3"}) case "/api/v1/repos/owner/project/releases": assertRequest(t, r, http.MethodPost, r.URL.Path) var payload createReleaseRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatal(err) } if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease { t.Fatalf("create payload = %#v", payload) } created = true hijacker, ok := w.(http.Hijacker) if !ok { t.Fatal("HTTP writer does not support hijacking") } connection, _, err := hijacker.Hijack() if err != nil { t.Fatal(err) } _ = connection.Close() default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } })) defer server.Close() result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if err != nil { t.Fatal(err) } if result.Created || lookups != 2 { t.Fatalf("result = %#v, release lookups = %d", result, lookups) } } func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) { lookups := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAuth(t, r) switch r.URL.Path { case "/api/v1/repos/owner/project/tags/v1.2.3": assertRequest(t, r, http.MethodGet, r.URL.Path) writeTag(t, w, testCommit) case "/api/v1/repos/owner/project/releases/tags/v1.2.3": assertRequest(t, r, http.MethodGet, r.URL.Path) lookups++ if lookups == 1 { http.NotFound(w, r) return } writeRelease(t, w, releaseFixture{title: "Created by another request"}) case "/api/v1/repos/owner/project/releases": assertRequest(t, r, http.MethodPost, r.URL.Path) w.WriteHeader(http.StatusConflict) default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } })) defer server.Close() result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if err != nil { t.Fatal(err) } if result.Created || result.Release.Title != "Created by another request" || lookups != 2 { t.Fatalf("result = %#v, release lookups = %d", result, lookups) } } func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) { lookups := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAuth(t, r) switch r.URL.Path { case "/api/v1/repos/owner/project/tags/v1.2.3": assertRequest(t, r, http.MethodGet, r.URL.Path) writeTag(t, w, testCommit) case "/api/v1/repos/owner/project/releases/tags/v1.2.3": assertRequest(t, r, http.MethodGet, r.URL.Path) lookups++ if lookups == 1 { http.NotFound(w, r) return } http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) case "/api/v1/repos/owner/project/releases": assertRequest(t, r, http.MethodPost, r.URL.Path) w.WriteHeader(http.StatusInternalServerError) default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } })) defer server.Close() _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if failureKind(t, err) != FailureUnknown || lookups != 2 { t.Fatalf("error = %v, release lookups = %d", err, lookups) } } type releaseFixture struct { title string body string draft bool pre bool } type fixtureState struct{ posts int } func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) { t.Helper() state := &fixtureState{} handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAuth(t, r) switch r.URL.Path { case "/api/v1/repos/owner/project/tags/v1.2.3": assertRequest(t, r, http.MethodGet, r.URL.Path) writeTag(t, w, testCommit) case "/api/v1/repos/owner/project/releases/tags/v1.2.3": assertRequest(t, r, http.MethodGet, r.URL.Path) if existing.title == "" && existing.body == "" { http.NotFound(w, r) return } writeRelease(t, w, existing) case "/api/v1/repos/owner/project/releases": assertRequest(t, r, http.MethodPost, r.URL.Path) state.posts++ var payload createReleaseRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatal(err) } if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease { t.Fatalf("create payload = %#v", payload) } if !createMissing { w.WriteHeader(http.StatusConflict) return } writeRelease(t, w, releaseFixture{title: payload.Name}) default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } }) server := httptest.NewServer(handler) return server, state } func writeTag(t *testing.T, w http.ResponseWriter, commit string) { t.Helper() w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit) } func writeRelease(t *testing.T, w http.ResponseWriter, fixture releaseFixture) { t.Helper() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, HTMLURL: "https://gitea.example/releases/10", TagName: "v1.2.3", Name: fixture.title, Body: fixture.bodyOrDefault(), Draft: fixture.draft, Prerelease: fixture.pre}) } func (fixture releaseFixture) bodyOrDefault() string { if fixture.body == "" { return testNote } return fixture.body } func assertRequest(t *testing.T, request *http.Request, method, path string) { t.Helper() if request.Method != method || request.URL.Path != path { t.Fatalf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path) } assertAuth(t, request) } func assertAuth(t *testing.T, request *http.Request) { t.Helper() if got := request.Header.Get("Authorization"); got != "token "+testToken { t.Fatalf("Authorization = %q", got) } } func testClient(server *httptest.Server) Client { return Client{HTTPClient: server.Client()} } func testRequest(baseURL string) Request { return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Tag: "v1.2.3", Commit: testCommit, Note: testNote} } func failureKind(t *testing.T, err error) FailureKind { t.Helper() var failure *Failure if !errors.As(err, &failure) { t.Fatalf("error type = %T (%v), want *Failure", err, err) } return failure.Kind } func contains(value, substring string) bool { return len(substring) > 0 && len(value) >= len(substring) && strings.Contains(value, substring) } func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) { for _, mode := range []string{"matching", "conflicting", "unavailable"} { t.Run(mode, func(t *testing.T) { posts, lookups := 0, 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/tags/") && !strings.Contains(r.URL.Path, "/releases/"): writeTag(t, w, testCommit) case strings.Contains(r.URL.Path, "/releases/tags/"): lookups++ if posts == 0 { http.NotFound(w, r) return } if mode == "unavailable" { http.Error(w, "unavailable", 503) return } body := testNote if mode == "conflicting" { body = "other content" } writeRelease(t, w, releaseFixture{title: "Example v1.2.3", body: body}) case r.Method == http.MethodPost: posts++ w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3", Name: "inconsistent title", Body: testNote}) default: t.Errorf("unexpected request %s", r.URL.Path) } })) defer server.Close() result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) if posts != 1 || lookups != 2 { t.Fatalf("posts=%d lookups=%d", posts, lookups) } switch mode { case "matching": if err != nil || !result.WriteAttempted { t.Fatalf("result=%#v err=%v", result, err) } case "conflicting": if failureKind(t, err) != FailureConflict { t.Fatal(err) } case "unavailable": if failureKind(t, err) != FailureUnknown { t.Fatal(err) } } }) } }