package gitea import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strconv" "strings" "sync/atomic" "testing" "go.maximumdirect.net/releasekit/internal/forge" ) func TestBackendOpenAssetBoundsRedirects(t *testing.T) { for _, hops := range []int{0, 10, 11} { t.Run(strconv.Itoa(hops), func(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" { t.Errorf("authorization = %q", got) } step, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/")) if err != nil { t.Errorf("invalid path %q", r.URL.Path) return } if step < hops { http.Redirect(w, r, fmt.Sprintf("/%d", step+1), http.StatusFound) return } _, _ = io.WriteString(w, "asset") })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) stream, err := backend.OpenAsset(t.Context(), assetHandle{downloadURL: "/0"}) if hops <= 10 { if err != nil { t.Fatal(err) } body, readErr := io.ReadAll(stream) closeErr := stream.Close() if readErr != nil || closeErr != nil || string(body) != "asset" || requests.Load() != int32(hops+1) { t.Fatalf("body = %q, read = %v, close = %v, requests = %d", body, readErr, closeErr, requests.Load()) } return } var failure *forge.Failure if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "redirect limit") || requests.Load() != 11 || stream != nil { t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load()) } }) } } func TestBackendOpenAssetRedirectReturnsToOrigin(t *testing.T) { var origin *httptest.Server external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "" { t.Errorf("cross-origin authorization = %q", got) } http.Redirect(w, r, origin.URL+"/final", http.StatusFound) })) defer external.Close() origin = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" { t.Errorf("origin authorization = %q", got) } if r.URL.Path == "/start" { http.Redirect(w, r, external.URL+"/away", http.StatusFound) return } _, _ = io.WriteString(w, "asset") })) defer origin.Close() stream, err := NewBackend(testDestination(origin.URL), origin.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/start"}) if err != nil { t.Fatal(err) } defer stream.Close() if body, err := io.ReadAll(stream); err != nil || string(body) != "asset" { t.Fatalf("body = %q, error = %v", body, err) } } func TestBackendOpenAssetRejectsRedirectCycle(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) http.Redirect(w, r, "/loop#different-fragment", http.StatusFound) })) defer server.Close() stream, err := NewBackend(testDestination(server.URL), server.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/loop"}) var failure *forge.Failure if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "loop") || requests.Load() != 1 || stream != nil { t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load()) } } func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) { 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/10/assets" { 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) } _, _ = io.WriteString(w, "null") })) defer server.Close() _, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10}) var failure *forge.Failure if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed { t.Fatalf("ListAssets() error = %v, want malformed attachment response", err) } } func TestBackendOpenAssetFollowsSameOriginRedirectWithAuthorization(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 at %s = %q, want %q", r.URL.Path, got, want) } switch r.URL.Path { case "/files/redirect": http.Redirect(w, r, "/files/content", http.StatusFound) case "/files/content": _, _ = io.WriteString(w, "streamed asset") default: t.Errorf("unexpected path %q", r.URL.Path) http.NotFound(w, r) } })) defer server.Close() backend := NewBackend(testDestination(server.URL), server.Client()) stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: "/files/redirect"}) if err != nil { t.Fatal(err) } content, readErr := io.ReadAll(stream) closeErr := stream.Close() if readErr != nil || closeErr != nil || string(content) != "streamed asset" { t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr) } } func TestBackendOpenAssetDoesNotForwardAuthorizationAcrossOrigin(t *testing.T) { for _, mode := range []string{"direct", "redirect"} { t.Run(mode, func(t *testing.T) { var externalRequests atomic.Int32 external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { externalRequests.Add(1) if got := r.Header.Get("Authorization"); got != "" { t.Errorf("external authorization = %q, want empty", got) } _, _ = io.WriteString(w, "external asset") })) defer external.Close() origin := 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("origin authorization = %q, want %q", got, want) } http.Redirect(w, r, external.URL+"/content", http.StatusFound) })) defer origin.Close() downloadURL := external.URL + "/content" if mode == "redirect" { downloadURL = "/redirect" } backend := NewBackend(testDestination(origin.URL), origin.Client()) stream, err := backend.OpenAsset(context.Background(), assetHandle{downloadURL: downloadURL}) if err != nil { t.Fatal(err) } content, readErr := io.ReadAll(stream) closeErr := stream.Close() if readErr != nil || closeErr != nil || string(content) != "external asset" || externalRequests.Load() != 1 { t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v, external requests = %d", content, readErr, closeErr, externalRequests.Load()) } }) } } func TestBackendListAssetsDecodesAttachmentMetadata(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}}) })) defer server.Close() assets, err := NewBackend(testDestination(server.URL), server.Client()).ListAssets(context.Background(), releaseHandle{id: 10}) 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) } }