package gitea import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "reflect" "strings" "sync" "testing" ) func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) { fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"} server := httptest.NewServer(fixture) defer server.Close() client := testClient(server) destination := testDestination(server.URL) assets := localAssets(t, []string{"zeta.bin", "alpha.bin", "beta.bin"}) manifest, err := BuildManifest(assets) if err != nil { t.Fatal(err) } assets = append(assets, manifest) initial, err := client.InspectAssets(context.Background(), destination, 10, assets) if err != nil { t.Fatal(err) } first, err := client.UploadMissing(context.Background(), destination, 10, initial) if err == nil || first.State != DeliveryPartial { t.Fatalf("first upload result = %#v, error = %v", first, err) } if got, want := names(first.Uploaded), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("uploaded = %v, want %v", got, want) } if got, want := names(first.Pending), []string{"zeta.bin", "beta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) { t.Fatalf("pending = %v, want %v", got, want) } assertAssetCategoriesDisjoint(t, first) secondInspection, err := client.InspectAssets(context.Background(), destination, 10, assets) if err != nil { t.Fatal(err) } second, err := client.UploadMissing(context.Background(), destination, 10, secondInspection) if err != nil || second.State != DeliveryComplete { t.Fatalf("second upload result = %#v, error = %v", second, err) } if got, want := names(second.Uploaded), []string{"beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) { t.Fatalf("second uploaded = %v, want %v", got, want) } if got, want := fixture.postNames(), []string{"alpha.bin", "beta.bin", "beta.bin", "zeta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) { t.Fatalf("POST order = %v, want %v", got, want) } } func TestUploadMissingAcceptsConcurrentMatchingAttachmentWithoutReplacement(t *testing.T) { fixture := &uploadFixture{files: make(map[string][]byte), conflictName: "app.bin"} server := httptest.NewServer(fixture) defer server.Close() client := testClient(server) destination := testDestination(server.URL) assets := localAssets(t, []string{"app.bin"}) initial, err := client.InspectAssets(context.Background(), destination, 10, assets) if err != nil { t.Fatal(err) } result, err := client.UploadMissing(context.Background(), destination, 10, initial) if err != nil || result.State != DeliveryComplete { t.Fatalf("result = %#v, error = %v", result, err) } if got, want := names(result.Matching), []string{"app.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("matching = %v, want %v", got, want) } if len(result.Uploaded) != 0 || len(fixture.postNames()) != 1 { t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames()) } assertAssetCategoriesDisjoint(t, result) } func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) { assets := localAssets(t, []string{"app.bin"}) manifest, err := BuildManifest(assets) if err != nil { t.Fatal(err) } fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: ManifestFilename} // The local binary is represented by its file path; the server starts with // that exact content so only the checksum manifest needs uploading. fixture.files["app.bin"] = readExpectedFile(t, assets[0]) server := httptest.NewServer(fixture) defer server.Close() client := testClient(server) destination := testDestination(server.URL) expected := append(assets, manifest) initial, err := client.InspectAssets(context.Background(), destination, 10, expected) if err != nil { t.Fatal(err) } result, err := client.UploadMissing(context.Background(), destination, 10, initial) if err != nil || result.State != DeliveryComplete { t.Fatalf("result = %#v, error = %v", result, err) } if got, want := names(result.Matching), []string{"app.bin", ManifestFilename}; !reflect.DeepEqual(got, want) { t.Fatalf("matching = %v, want %v", got, want) } if len(result.Uploaded) != 0 || !reflect.DeepEqual(fixture.postNames(), []string{ManifestFilename}) { t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames()) } assertAssetCategoriesDisjoint(t, result) } func TestUploadMissingReportsUnknownWhenReconciliationReadFails(t *testing.T) { fixture := &uploadFixture{files: make(map[string][]byte), dropAckName: "app.bin", failListAfter: 1} server := httptest.NewServer(fixture) defer server.Close() client := testClient(server) destination := testDestination(server.URL) assets := localAssets(t, []string{"app.bin", "later.bin"}) initial, err := client.InspectAssets(context.Background(), destination, 10, assets) if err != nil { t.Fatal(err) } result, err := client.UploadMissing(context.Background(), destination, 10, initial) if err == nil || result.State != DeliveryUnknown { t.Fatalf("result = %#v, error = %v", result, err) } if got, want := names(result.Pending), []string{"later.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("pending = %v, want %v", got, want) } if got, want := names(result.Uncertain), []string{"app.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("uncertain = %v, want %v", got, want) } assertAssetCategoriesDisjoint(t, result) if got := fixture.postNames(); !reflect.DeepEqual(got, []string{"app.bin"}) { t.Fatalf("POST names = %v", got) } } func TestUploadMissingPreflightConflictPreventsEveryWrite(t *testing.T) { fixture := &uploadFixture{files: map[string][]byte{"app.bin": []byte("different bytes")}} server := httptest.NewServer(fixture) defer server.Close() client := testClient(server) destination := testDestination(server.URL) assets := localAssets(t, []string{"app.bin", "other.bin"}) inspection, err := client.InspectAssets(context.Background(), destination, 10, assets) if err != nil { t.Fatal(err) } result, err := client.UploadMissing(context.Background(), destination, 10, inspection) if err == nil || result.State != DeliveryConflict { t.Fatalf("result = %#v, error = %v", result, err) } if len(fixture.postNames()) != 0 { t.Fatalf("uploads happened before conflict review: %v", fixture.postNames()) } if got, want := names(result.Conflicting), []string{"app.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("conflicting = %v, want %v", got, want) } if got, want := names(result.Pending), []string{"other.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("pending = %v, want %v", got, want) } assertAssetCategoriesDisjoint(t, result) } func TestUploadMissingKeepsLaterPendingAfterAmbiguousMissingUpload(t *testing.T) { fixture := &uploadFixture{files: make(map[string][]byte), dropAckMissingName: "alpha.bin"} server := httptest.NewServer(fixture) defer server.Close() client := testClient(server) destination := testDestination(server.URL) assets := localAssets(t, []string{"alpha.bin", "zeta.bin"}) initial, err := client.InspectAssets(context.Background(), destination, 10, assets) if err != nil { t.Fatal(err) } result, err := client.UploadMissing(context.Background(), destination, 10, initial) if err == nil || result.State != DeliveryUnknown { t.Fatalf("result = %#v, error = %v", result, err) } if got, want := names(result.Uncertain), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("uncertain = %v, want %v", got, want) } if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("pending = %v, want %v", got, want) } assertAssetCategoriesDisjoint(t, result) if got, want := fixture.postNames(), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("POST names = %v, want %v", got, want) } } func TestUploadMissingMovesReconciledConflictOutOfPending(t *testing.T) { fixture := &uploadFixture{ files: make(map[string][]byte), conflictName: "alpha.bin", conflictDifferentContent: true, } server := httptest.NewServer(fixture) defer server.Close() client := testClient(server) destination := testDestination(server.URL) assets := localAssets(t, []string{"alpha.bin", "zeta.bin"}) initial, err := client.InspectAssets(context.Background(), destination, 10, assets) if err != nil { t.Fatal(err) } result, err := client.UploadMissing(context.Background(), destination, 10, initial) if err == nil || result.State != DeliveryConflict { t.Fatalf("result = %#v, error = %v", result, err) } if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("conflicting = %v, want %v", got, want) } if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("pending = %v, want %v", got, want) } assertAssetCategoriesDisjoint(t, result) } func TestUploadMissingTreatsUnresolvedNameConflictAsConflict(t *testing.T) { fixture := &uploadFixture{files: make(map[string][]byte), conflictMissingName: "alpha.bin"} server := httptest.NewServer(fixture) defer server.Close() client := testClient(server) destination := testDestination(server.URL) assets := localAssets(t, []string{"alpha.bin", "zeta.bin"}) initial, err := client.InspectAssets(context.Background(), destination, 10, assets) if err != nil { t.Fatal(err) } result, err := client.UploadMissing(context.Background(), destination, 10, initial) if err == nil || result.State != DeliveryConflict { t.Fatalf("result = %#v, error = %v", result, err) } if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("conflicting = %v, want %v", got, want) } if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) { t.Fatalf("pending = %v, want %v", got, want) } assertAssetCategoriesDisjoint(t, result) } type uploadFixture struct { mu sync.Mutex files map[string][]byte posts []string failName string conflictName string conflictDifferentContent bool conflictMissingName string dropAckName string dropAckMissingName string failListAfter int listCount int } func (fixture *uploadFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "token "+testToken { http.Error(w, "unauthorized", http.StatusUnauthorized) return } if r.Method == http.MethodGet && r.URL.Path == assetListPath { fixture.mu.Lock() fixture.listCount++ if fixture.failListAfter > 0 && fixture.listCount > fixture.failListAfter { fixture.mu.Unlock() http.Error(w, "read failed", http.StatusServiceUnavailable) return } assets := make([]assetResponse, 0, len(fixture.files)) for name, content := range fixture.files { assets = append(assets, assetResponse{ID: int64(len(assets) + 1), Name: name, Size: int64(len(content)), BrowserDownloadURL: "/files/" + urlPathEscape(name)}) } fixture.mu.Unlock() _ = json.NewEncoder(w).Encode(assets) return } if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/files/") { name := strings.TrimPrefix(r.URL.Path, "/files/") fixture.mu.Lock() content, ok := fixture.files[name] fixture.mu.Unlock() if !ok { http.NotFound(w, r) return } _, _ = w.Write(content) return } if r.Method == http.MethodPost && r.URL.Path == assetListPath { fixture.receiveUpload(w, r) return } http.Error(w, "unexpected request", http.StatusMethodNotAllowed) } func (fixture *uploadFixture) receiveUpload(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data; boundary=") { http.Error(w, "multipart required", http.StatusBadRequest) return } if err := r.ParseMultipartForm(1 << 20); err != nil { http.Error(w, "invalid multipart body", http.StatusBadRequest) return } file, header, err := r.FormFile("attachment") if err != nil { http.Error(w, "missing attachment field", http.StatusBadRequest) return } defer file.Close() content, err := io.ReadAll(file) if err != nil { http.Error(w, "read multipart file", http.StatusBadRequest) return } if header.Filename != r.URL.Query().Get("name") { http.Error(w, "filename query mismatch", http.StatusBadRequest) return } name := header.Filename fixture.mu.Lock() fixture.posts = append(fixture.posts, name) attempts := 0 for _, posted := range fixture.posts { if posted == name { attempts++ } } if name == fixture.failName && attempts == 1 { fixture.mu.Unlock() http.Error(w, "write failed", http.StatusBadRequest) return } if name == fixture.dropAckMissingName { fixture.mu.Unlock() hijacker, ok := w.(http.Hijacker) if !ok { http.Error(w, "hijacking unavailable", http.StatusInternalServerError) return } connection, _, err := hijacker.Hijack() if err == nil { _ = connection.Close() } return } if name == fixture.conflictMissingName { fixture.mu.Unlock() w.WriteHeader(http.StatusConflict) return } if name == fixture.conflictName && fixture.conflictDifferentContent { fixture.files[name] = []byte("conflicting bytes") fixture.mu.Unlock() w.WriteHeader(http.StatusConflict) return } fixture.files[name] = append([]byte(nil), content...) fixture.mu.Unlock() if name == fixture.conflictName { w.WriteHeader(http.StatusConflict) return } if name == fixture.dropAckName { hijacker, ok := w.(http.Hijacker) if !ok { http.Error(w, "hijacking unavailable", http.StatusInternalServerError) return } connection, _, err := hijacker.Hijack() if err == nil { _ = connection.Close() } return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: name, Size: int64(len(content))}) } func localAssets(t *testing.T, names []string) []ExpectedAsset { t.Helper() output := t.TempDir() assets := make([]ExpectedAsset, 0, len(names)) for _, name := range names { content := []byte("binary-" + name) path := filepath.Join(output, name) if err := os.WriteFile(path, content, 0o600); err != nil { t.Fatal(err) } sum := assetFor(name, content) sum.Content = nil sum.Path = path assets = append(assets, sum) } return assets } func readExpectedFile(t *testing.T, asset ExpectedAsset) []byte { t.Helper() content, err := os.ReadFile(asset.Path) if err != nil { t.Fatal(err) } return content } func (fixture *uploadFixture) postNames() []string { fixture.mu.Lock() defer fixture.mu.Unlock() return append([]string(nil), fixture.posts...) } func names(identities []AssetIdentity) []string { result := make([]string, 0, len(identities)) for _, identity := range identities { result = append(result, identity.Filename) } return result } func assertAssetCategoriesDisjoint(t *testing.T, result UploadResult) { t.Helper() groups := []struct { name string identities []AssetIdentity }{ {name: "matching", identities: result.Matching}, {name: "uploaded", identities: result.Uploaded}, {name: "pending", identities: result.Pending}, {name: "conflicting", identities: result.Conflicting}, {name: "uncertain", identities: result.Uncertain}, } seen := make(map[string]string) for _, group := range groups { for _, item := range group.identities { if previous, ok := seen[item.Filename]; ok { t.Errorf("asset %q appears in both %s and %s", item.Filename, previous, group.name) } seen[item.Filename] = group.name } } } func urlPathEscape(value string) string { return (&url.URL{Path: value}).EscapedPath() }