// SPDX-License-Identifier: AGPL-3.0-or-later package web import ( "context" "errors" "mime" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "bnema.dev/ermite/internal/domain" "bnema.dev/ermite/internal/ports" "bnema.dev/ermite/internal/ports/mocks" ) type releaseFixture struct { srv *Server svc *mocks.ReleaseService published, draft domain.Release asset domain.ReleaseAsset data string downloadErr error failAfterMetadata bool downloads int tokens []string } func newReleaseFixture(t *testing.T) *releaseFixture { t.Helper() repo := repoName(t, "proj") f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"} f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) \n\n[guide](guide.md)\n\n```sh\ncurl https://example.com\n```", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true} f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true} f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)} f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) { if ref.Latest || ref.Tag == f.published.Tag { return f.published, nil } if ref.Tag == f.draft.Tag && p.IsOwner() { return f.draft, nil } return domain.Release{}, domain.ErrNotFound }).Maybe() f.svc.EXPECT().ListReleases(mock.Anything, mock.Anything, repo, 30, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, _ int, token string) (ports.ReleasePage, error) { f.tokens = append(f.tokens, token) rows := []domain.Release{f.published} if p.IsOwner() { rows = append([]domain.Release{f.draft}, rows...) } return ports.ReleasePage{Releases: rows, NextPageToken: "next+/="}, nil }).Maybe() f.svc.EXPECT().ListAssets(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, id int64) ([]domain.ReleaseAsset, error) { if id == f.published.ID { return []domain.ReleaseAsset{f.asset}, nil } if id == f.draft.ID && p.IsOwner() { return nil, nil } return nil, domain.ErrNotFound }).Maybe() f.svc.EXPECT().GetAsset(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) { if ref.ID == f.asset.ID || (ref.Name == f.asset.Name && (ref.Latest || ref.Tag == f.published.Tag)) { return f.asset, nil } return domain.ReleaseAsset{}, domain.ErrNotFound }).Maybe() f.svc.EXPECT().DownloadAsset(mock.Anything, mock.Anything, repo, ports.AssetRef{ID: 7}, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error { f.downloads++ deadline, ok := ctx.Deadline() require.True(t, ok) assert.InDelta(t, 3600, time.Until(deadline).Seconds(), 2) if f.downloadErr != nil && !f.failAfterMetadata { return f.downloadErr } if err := send(ports.AssetChunk{Asset: &f.asset}); err != nil { return err } if f.downloadErr != nil { return f.downloadErr } return send(ports.AssetChunk{Data: []byte(f.data[offset : offset+length])}) }).Maybe() browse := mocks.NewBrowseService(t) browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe() f.srv = newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Releases: f.svc, Sessions: newSessions(t, "tok"), Limiter: &blockingLimiter{}, Now: func() time.Time { return fixedNow }}) return f } func (f *releaseFixture) request(method, path, cookie, byteRange string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, path, nil) if cookie != "" { r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie}) } if byteRange != "" { r.Header.Set("Range", byteRange) } w := httptest.NewRecorder() f.srv.ServeHTTP(w, r) return w } func TestReleasePages(t *testing.T) { f := newReleaseFixture(t) for _, path := range []string{"/proj/releases", "/proj/releases/"} { w := f.request("GET", path, "", "") require.Equal(t, 200, w.Code) body := w.Body.String() assert.Contains(t, body, "First release") assert.NotContains(t, body, "Secret draft") assert.Contains(t, body, ">Latest") assert.Contains(t, body, ">Pre-release") assert.Contains(t, body, `href="/proj/releases/tag/stable/v1"`) assert.Contains(t, body, "2026-10-12") assert.Contains(t, body, `