package gitea import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "sync/atomic" "testing" ) const assetListPath = "/api/v1/repos/owner/project/releases/10/assets" func TestBuildManifestSortsNamesAndOmitsEmptySourceOnlyManifest(t *testing.T) { manifest, err := BuildManifest([]ExpectedAsset{ assetFor("zeta.bin", []byte("zeta")), assetFor("alpha.bin", []byte("alpha")), }) if err != nil { t.Fatal(err) } want := fmt.Sprintf("%s alpha.bin\n%s zeta.bin\n", digest([]byte("alpha")), digest([]byte("zeta"))) if string(manifest.Content) != want || manifest.Filename != ManifestFilename || manifest.SHA256 != digest([]byte(want)) || manifest.Size != int64(len(want)) { t.Fatalf("manifest = %#v, content %q", manifest, manifest.Content) } empty, err := BuildManifest(nil) if err != nil || empty.Filename != "" || len(empty.Content) != 0 { t.Fatalf("empty manifest = %#v, error = %v", empty, err) } } func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testing.T) { content := []byte("selected binary") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAssetGET(t, r, "") switch r.URL.Path { case assetListPath: writeAssetList(t, w, []assetResponse{ {ID: 4, Name: "app-v1.2.3-linux-amd64", Size: int64(len(content)), BrowserDownloadURL: "/files/app"}, {ID: 8, Name: "notes.txt", Size: 9, BrowserDownloadURL: "/files/notes"}, }) case "/files/app": _, _ = w.Write(content) case "/files/notes": _, _ = w.Write([]byte("unrelated")) default: t.Errorf("unexpected path %s", r.URL.Path) return } })) defer server.Close() inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{assetFor("app-v1.2.3-linux-amd64", content)}) if err != nil { t.Fatal(err) } if len(inspection.Assets) != 1 || inspection.Assets[0].State != AssetMatching { t.Fatalf("inspection = %#v", inspection) } if len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "notes.txt" { t.Fatalf("unrelated = %#v", inspection.Unrelated) } } func TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes(t *testing.T) { expected := []ExpectedAsset{assetFor("app.bin", []byte("alpha")), assetFor("other.bin", []byte("beta"))} var appDownloads, otherDownloads atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAssetGET(t, r, "") if r.URL.Path == assetListPath { writeAssetList(t, w, []assetResponse{ {ID: 2, Name: expected[0].Filename, Size: 5, BrowserDownloadURL: "/files/app"}, {ID: 3, Name: expected[1].Filename, Size: 4, BrowserDownloadURL: "/files/other"}, }) return } if r.URL.Path == "/files/app" { appDownloads.Add(1) _, _ = w.Write([]byte("bravo")) return } otherDownloads.Add(1) _, _ = w.Write([]byte("beta")) })) defer server.Close() inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, expected) if err != nil { t.Fatal(err) } if inspection.Assets[0].State != AssetConflicting || inspection.Assets[1].State != AssetMatching || appDownloads.Load() != 1 || otherDownloads.Load() != 1 { t.Fatalf("inspection = %#v, downloads = %d and %d", inspection, appDownloads.Load(), otherDownloads.Load()) } } func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) { binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))} manifest, err := BuildManifest(binaries) if err != nil { t.Fatal(err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAssetGET(t, r, "") if r.URL.Path == assetListPath { writeAssetList(t, w, []assetResponse{{ID: 3, Name: manifest.Filename, Size: manifest.Size, BrowserDownloadURL: "/files/manifest"}}) return } if r.URL.Path != "/files/manifest" { t.Errorf("unexpected path %s", r.URL.Path) return } _, _ = w.Write(manifest.Content) })) defer server.Close() inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, append(binaries, manifest)) if err != nil { t.Fatal(err) } if len(inspection.Assets) != 3 || inspection.Assets[0].State != AssetMissing || inspection.Assets[1].State != AssetMissing || inspection.Assets[2].State != AssetMatching { t.Fatalf("inspection = %#v", inspection) } } func TestInspectAssetsRejectsSubsetManifest(t *testing.T) { binaries := []ExpectedAsset{assetFor("alpha.bin", []byte("alpha")), assetFor("beta.bin", []byte("beta"))} full, err := BuildManifest(binaries) if err != nil { t.Fatal(err) } subset, err := BuildManifest(binaries[:1]) if err != nil { t.Fatal(err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAssetGET(t, r, "") if r.URL.Path == assetListPath { writeAssetList(t, w, []assetResponse{{ID: 3, Name: ManifestFilename, Size: subset.Size, BrowserDownloadURL: "/files/manifest"}}) return } _, _ = w.Write(subset.Content) })) defer server.Close() inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{full}) if err != nil { t.Fatal(err) } if inspection.Assets[0].State != AssetConflicting { t.Fatalf("inspection = %#v", inspection) } } func TestInspectAssetsRejectsDifferentManifest(t *testing.T) { expected, err := BuildManifest([]ExpectedAsset{assetFor("alpha.bin", []byte("alpha"))}) if err != nil { t.Fatal(err) } different := []byte("not the declared checksum list\n") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAssetGET(t, r, "") if r.URL.Path == assetListPath { writeAssetList(t, w, []assetResponse{{ID: 3, Name: ManifestFilename, Size: int64(len(different)), BrowserDownloadURL: "/files/manifest"}}) return } _, _ = w.Write(different) })) defer server.Close() inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected}) if err != nil { t.Fatal(err) } if inspection.Assets[0].State != AssetConflicting { t.Fatalf("inspection = %#v", inspection) } } func TestInspectAssetsMarksUnreadableContentUnverifiable(t *testing.T) { expected := assetFor("app.bin", []byte("alpha")) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAssetGET(t, r, "") if r.URL.Path == assetListPath { writeAssetList(t, w, []assetResponse{{ID: 2, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/app"}}) return } http.Error(w, "forbidden", http.StatusForbidden) })) defer server.Close() inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected}) if err != nil { t.Fatal(err) } if inspection.Assets[0].State != AssetUnverifiable || inspection.Assets[0].Err == nil { t.Fatalf("inspection = %#v", inspection) } } func TestInspectAssetsRejectsMalformedAttachmentList(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAssetGET(t, r, assetListPath) _, _ = w.Write([]byte("null")) })) defer server.Close() _, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{assetFor("app.bin", []byte("alpha"))}) if failureKind(t, err) != FailureMalformed { t.Fatalf("error = %v", err) } } func TestInspectAssetsSourceOnlyDoesNotDeclareManifest(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAssetGET(t, r, assetListPath) writeAssetList(t, w, []assetResponse{{ID: 7, Name: "historical.txt", Size: 3}}) })) defer server.Close() inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, nil) if err != nil { t.Fatal(err) } if len(inspection.Assets) != 0 || len(inspection.Unrelated) != 1 || inspection.Unrelated[0].Filename != "historical.txt" { t.Fatalf("inspection = %#v", inspection) } } func TestInspectAssetsFollowsSameOriginRedirectWithAuthorization(t *testing.T) { content := []byte("alpha") expected := assetFor("app.bin", content) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAssetGET(t, r, "") if r.URL.Path == assetListPath { writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/redirect"}}) return } if r.URL.Path == "/files/redirect" { http.Redirect(w, r, "/files/content", http.StatusFound) return } if r.URL.Path == "/files/content" { _, _ = w.Write(content) return } t.Errorf("unexpected path %s", r.URL.Path) return })) defer server.Close() inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected}) if err != nil { t.Fatal(err) } if inspection.Assets[0].State != AssetMatching { t.Fatalf("inspection = %#v", inspection) } } func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) { for _, mode := range []string{"direct", "redirect", "conflicting"} { t.Run(mode, func(t *testing.T) { expected := assetFor("app.bin", []byte("matching bytes")) var externalGets atomic.Int64 external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { externalGets.Add(1) if r.Header.Get("Authorization") != "" { t.Error("Gitea token reached external host") } content := []byte("matching bytes") if mode == "conflicting" { content = []byte("different bytes") } w.Write(content) })) defer external.Close() origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertAssetGET(t, r, "") if r.URL.Path == assetListPath { download := external.URL + "/content" if mode == "redirect" { download = "/redirect" } writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: download}}) return } http.Redirect(w, r, external.URL+"/content", http.StatusFound) })) defer origin.Close() inspection, err := testClient(origin).InspectAssets(context.Background(), testDestination(origin.URL), 10, []ExpectedAsset{expected}) if err != nil { t.Fatal(err) } want := AssetMatching if mode == "conflicting" { want = AssetConflicting } if inspection.Assets[0].State != want || externalGets.Load() != 1 { t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets.Load()) } }) } } func assetFor(filename string, content []byte) ExpectedAsset { sum := sha256.Sum256(content) return ExpectedAsset{Filename: filename, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(content)), Content: append([]byte(nil), content...)} } func writeAssetList(t *testing.T, w http.ResponseWriter, assets []assetResponse) { t.Helper() w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(assets); err != nil { t.Error(err) } } func assertAssetGET(t *testing.T, r *http.Request, expectedPath string) { t.Helper() if r.Method != http.MethodGet { t.Errorf("method = %q, want GET", r.Method) } if expectedPath != "" && r.URL.Path != expectedPath { t.Errorf("path = %q, want %q", r.URL.Path, expectedPath) } if got := r.Header.Get("Authorization"); got != "token "+testToken { t.Errorf("Authorization = %q", got) } } func testDestination(base string) Destination { return Destination{BaseURL: base, Owner: "owner", Repository: "project", Token: testToken} }