453 lines
15 KiB
Go
453 lines
15 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"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.Add(1)
|
|
var payload createReleaseRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote {
|
|
t.Errorf("create payload = %#v", payload)
|
|
return
|
|
}
|
|
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.Error(err)
|
|
return
|
|
}
|
|
default:
|
|
t.Errorf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath())
|
|
return
|
|
}
|
|
}))
|
|
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.Load() != 1 {
|
|
t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts.Load(), 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.Load(); 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) {
|
|
var posts atomic.Int64
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
posts.Add(1)
|
|
}
|
|
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.Load() != 0 {
|
|
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
|
}
|
|
}
|
|
|
|
func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) {
|
|
var posts atomic.Int64
|
|
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.Add(1)
|
|
}
|
|
_, _ = 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.Load() != 0 {
|
|
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
|
}
|
|
}
|
|
|
|
func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) {
|
|
var posts atomic.Int64
|
|
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.Add(1)
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer server.Close()
|
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
if failureKind(t, err) != FailureNotFound || posts.Load() != 0 {
|
|
t.Fatalf("error = %v, posts = %d", err, posts.Load())
|
|
}
|
|
}
|
|
|
|
func TestEnsureReleaseHonorsContextCancellation(t *testing.T) {
|
|
var called atomic.Bool
|
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called.Store(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.Load() {
|
|
t.Fatalf("error = %v, server called = %t", err, called.Load())
|
|
}
|
|
}
|
|
|
|
func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) {
|
|
var created atomic.Bool
|
|
var lookups atomic.Int64
|
|
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.Add(1)
|
|
if !created.Load() {
|
|
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.Error(err)
|
|
return
|
|
}
|
|
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
|
t.Errorf("create payload = %#v", payload)
|
|
return
|
|
}
|
|
created.Store(true)
|
|
hijacker, ok := w.(http.Hijacker)
|
|
if !ok {
|
|
t.Error("HTTP writer does not support hijacking")
|
|
return
|
|
}
|
|
connection, _, err := hijacker.Hijack()
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
_ = connection.Close()
|
|
default:
|
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
return
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Created || lookups.Load() != 2 {
|
|
t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
|
|
}
|
|
}
|
|
|
|
func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
|
|
var lookups atomic.Int64
|
|
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.Add(1)
|
|
if lookups.Load() == 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.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
return
|
|
}
|
|
}))
|
|
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.Load() != 2 {
|
|
t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
|
|
}
|
|
}
|
|
|
|
func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) {
|
|
var lookups atomic.Int64
|
|
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.Add(1)
|
|
if lookups.Load() == 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.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
return
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
|
if failureKind(t, err) != FailureUnknown || lookups.Load() != 2 {
|
|
t.Fatalf("error = %v, release lookups = %d", err, lookups.Load())
|
|
}
|
|
}
|
|
|
|
type releaseFixture struct {
|
|
title string
|
|
body string
|
|
draft bool
|
|
pre bool
|
|
}
|
|
|
|
type fixtureState struct{ posts atomic.Int64 }
|
|
|
|
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.Add(1)
|
|
var payload createReleaseRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
|
|
t.Errorf("create payload = %#v", payload)
|
|
return
|
|
}
|
|
if !createMissing {
|
|
w.WriteHeader(http.StatusConflict)
|
|
return
|
|
}
|
|
writeRelease(t, w, releaseFixture{title: payload.Name})
|
|
default:
|
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
return
|
|
}
|
|
})
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
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.Errorf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path)
|
|
return
|
|
}
|
|
assertAuth(t, request)
|
|
}
|
|
|
|
func assertAuth(t *testing.T, request *http.Request) {
|
|
t.Helper()
|
|
if got := request.Header.Get("Authorization"); got != "token "+testToken {
|
|
t.Errorf("Authorization = %q", got)
|
|
return
|
|
}
|
|
}
|
|
|
|
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) {
|
|
var posts, lookups atomic.Int64
|
|
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.Add(1)
|
|
if posts.Load() == 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.Add(1)
|
|
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.Load() != 1 || lookups.Load() != 2 {
|
|
t.Fatalf("posts=%d lookups=%d", posts.Load(), lookups.Load())
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|