Go · 14215 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Smoke-level integration tests for the highest-traffic repo handler
4 // helpers. Two-pass authorization (per the S00–S25 audit, finding H7)
5 // is the kind of subtle logic that handler tests catch and orchestrator
6 // tests miss — so we cover the visibility/policy invariants directly
7 // here rather than indirectly through repos.Create or pulls.Merge.
8 //
9 // Skip-when-no-DB: dbtest.NewTestDB skips the test if
10 // SHITHUB_TEST_DATABASE_URL is unset, so unit-test machines without
11 // Postgres still go green.
12
13 package repo
14
15 import (
16 "context"
17 "errors"
18 "io"
19 "log/slog"
20 "net/http"
21 "net/http/httptest"
22 "strconv"
23 "strings"
24 "testing"
25 "testing/fstest"
26
27 "github.com/go-chi/chi/v5"
28 "github.com/jackc/pgx/v5"
29 "github.com/jackc/pgx/v5/pgtype"
30 "github.com/jackc/pgx/v5/pgxpool"
31
32 "github.com/tenseleyFlow/shithub/internal/auth/audit"
33 "github.com/tenseleyFlow/shithub/internal/auth/policy"
34 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
35 "github.com/tenseleyFlow/shithub/internal/infra/config"
36 "github.com/tenseleyFlow/shithub/internal/infra/storage"
37 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
38 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
39 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
40 "github.com/tenseleyFlow/shithub/internal/web/middleware"
41 "github.com/tenseleyFlow/shithub/internal/web/render"
42 )
43
44 // fixtureHash is a static argon2 PHC test fixture (zero salt, zero key)
45 // — not a real credential. Same shape as the one used in
46 // internal/repos/create_test.go.
47 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
48 "AAAAAAAAAAAAAAAA$" +
49 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
50
51 type repoFixture struct {
52 pool *pgxpool.Pool
53 handlers *Handlers
54 objectStore storage.ObjectStore
55 owner usersdb.User
56 stranger usersdb.User
57 publicRepo reposdb.Repo
58 privateRepo reposdb.Repo
59 }
60
61 // newRepoFixture sets up a Handlers wired to a fresh test DB with two
62 // users (owner alice, stranger bob) and two repos (public + private).
63 func newRepoFixture(t *testing.T) *repoFixture {
64 return newRepoFixtureWithEnforce(t, config.EnforceConfig{})
65 }
66
67 // newRepoFixtureWithEnforce mirrors newRepoFixture but plumbs an
68 // operator-configured per-feature enforce matrix into the handler
69 // Deps. PRO07 tests pass a config with the relevant user-kind flag
70 // flipped to true to exercise the enforce path.
71 func newRepoFixtureWithEnforce(t *testing.T, enforce config.EnforceConfig) *repoFixture {
72 t.Helper()
73 pool := dbtest.NewTestDB(t)
74
75 rfs, err := storage.NewRepoFS(t.TempDir())
76 if err != nil {
77 t.Fatalf("NewRepoFS: %v", err)
78 }
79 rr, err := render.New(minimalTemplatesFS(), render.Options{})
80 if err != nil {
81 t.Fatalf("render.New: %v", err)
82 }
83 objectStore := storage.NewMemoryStore()
84
85 h, err := New(Deps{
86 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
87 Render: rr,
88 Pool: pool,
89 RepoFS: rfs,
90 ObjectStore: objectStore,
91 Audit: audit.NewRecorder(),
92 Limiter: throttle.NewLimiter(),
93 BillingEnforce: enforce,
94 })
95 if err != nil {
96 t.Fatalf("New: %v", err)
97 }
98
99 uq := usersdb.New()
100 rq := reposdb.New()
101 ctx := context.Background()
102
103 owner, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
104 Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
105 })
106 if err != nil {
107 t.Fatalf("CreateUser alice: %v", err)
108 }
109 stranger, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
110 Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash,
111 })
112 if err != nil {
113 t.Fatalf("CreateUser bob: %v", err)
114 }
115
116 pubRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
117 OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
118 Name: "public-repo",
119 Description: "",
120 Visibility: reposdb.RepoVisibilityPublic,
121 DefaultBranch: "trunk",
122 })
123 if err != nil {
124 t.Fatalf("CreateRepo public: %v", err)
125 }
126 privRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
127 OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
128 Name: "private-repo",
129 Description: "",
130 Visibility: reposdb.RepoVisibilityPrivate,
131 DefaultBranch: "trunk",
132 })
133 if err != nil {
134 t.Fatalf("CreateRepo private: %v", err)
135 }
136
137 return &repoFixture{
138 pool: pool,
139 handlers: h,
140 objectStore: objectStore,
141 owner: owner,
142 stranger: stranger,
143 publicRepo: pubRepo,
144 privateRepo: privRepo,
145 }
146 }
147
148 // minimalTemplatesFS returns just the error pages that
149 // loadRepoAndAuthorize needs in order to render its 404/403 responses.
150 func minimalTemplatesFS() fstest.MapFS {
151 layout := []byte(`{{ define "layout" }}{{ template "page" . }}{{ end }}`)
152 body := []byte(`{{ define "page" }}{{ .StatusText }}: {{ .Message }}{{ end }}`)
153 return fstest.MapFS{
154 "_layout.html": {Data: layout},
155 "_repo_settings_nav.html": {Data: []byte(`{{ define "repo-settings-nav" }}NAV{{ end }}`)},
156 "errors/403.html": {Data: body},
157 "errors/404.html": {Data: body},
158 "errors/429.html": {Data: body},
159 "errors/500.html": {Data: body},
160 "repo/new.html": {Data: []byte(`{{ define "page" }}OWNERS={{ range .Owners }}{{ .Token }}:{{ if eq .Token $.Form.Owner }}selected{{ end }}:{{ .Slug }};{{ end }}{{ end }}`)},
161 "repo/actions.html": {Data: []byte(`{{ define "page" }}COUNT={{ .RunCount }};FILTERED={{ .FilteredRunCount }};PAGE={{ .Pagination.ResultText }};{{ range .DispatchWorkflows }}DISPATCH={{ .Name }}:{{ .DispatchHref }}:{{ range .Inputs }}{{ .Name }}/{{ .Type }}/{{ .Required }}/{{ .Default }}/{{ range .Options }}{{ .Value }}|{{ end }},{{ end }};{{ end }}{{ range .Workflows }}WF={{ .Name }}:{{ .Count }}:{{ .Active }};{{ end }}{{ range .Runs }}RUN={{ .Title }}:#{{ .RunIndex }}:{{ .Event }}:{{ .HeadRef }}:{{ .ActorUsername }}:{{ .StateClass }};{{ end }}{{ end }}`)},
162 "repo/_action_run_status.html": {Data: []byte(`{{ define "action-run-status" }}STATUS={{ .Run.StateClass }}:{{ .Run.IsTerminal }}:{{ .Run.StatusHref }};{{ end }}`)},
163 "repo/action_run.html": {Data: []byte(`{{ define "page" }}RUN={{ .Run.Title }}:#{{ .Run.RunIndex }}:{{ .Run.Event }}:{{ .Run.ActorUsername }}:{{ .Run.StateClass }};{{ if .Run.ParentRunHref }}PARENT={{ .Run.ParentRunIndex }}:{{ .Run.ParentRunHref }};{{ end }}{{ if .Run.CanRerun }}RERUN={{ .Run.RerunHref }};{{ end }}{{ if .Run.CanCancel }}CANCEL_RUN={{ .Run.CancelHref }};{{ end }}{{ if .Run.CanApprove }}APPROVE={{ .Run.ApproveHref }};REJECT={{ .Run.RejectHref }};{{ end }}{{ if .Run.ApprovalPending }}APPROVAL_PENDING={{ .Run.ApprovalReason }};{{ end }}SUMMARY={{ .Run.JobCount }}:{{ .Run.CompletedCount }}:{{ .Run.FailureCount }}:{{ .Run.ArtifactCount }};{{ range .Run.Jobs }}JOB={{ .Name }}:{{ .StateClass }}:{{ .NeedsText }}:{{ .RunsOn }};{{ if .WaitReason }}WAIT={{ .WaitReason }};{{ end }}{{ if .CanCancel }}CANCEL_JOB={{ .CancelHref }};{{ end }}{{ if .CancelRequested }}CANCEL_REQUESTED={{ .Name }};{{ end }}{{ range .Steps }}STEP={{ .Name }}:{{ .StateClass }}:{{ .LogHref }};{{ end }}{{ end }}{{ end }}`)},
164 "repo/action_run_status.html": {Data: []byte(`{{ define "page" }}{{ template "action-run-status" . }}{{ end }}`)},
165 "repo/action_step_log.html": {Data: []byte(`{{ define "page" }}STEPLOG={{ .Log.Job.Name }}:{{ .Log.Step.Name }}:{{ .Log.LogSource }}:{{ .Log.DownloadURL }}:{{ .Log.LogTruncated }};{{ with .Log.StreamHref }}STREAM={{ . }};{{ end }}{{ with .Log.LogError }}ERROR={{ . }};{{ end }}LOG={{ .Log.LogText }};{{ end }}`)},
166 "repo/settings_actions.html": {Data: []byte(`{{ define "page" }}POLICY={{ .Policy.ActionsEnabled }}:{{ .Policy.RequirePRApproval }}:{{ .Policy.EffectiveActionsEnabled }}:{{ .Policy.EffectiveRequirePRApproval }}:{{ .Policy.EffectiveMaxRepoQueuedRuns }};{{ with .Error }}ERROR={{ . }}{{ end }}{{ end }}`)},
167 "repo/settings_secrets.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ range .Secrets }}SECRET={{ .Name }};{{ end }}{{ range .Variables }}VAR={{ .Name }}:{{ .Value }};{{ end }}{{ end }}`)},
168 }
169 }
170
171 // withViewer attaches a CurrentUser to the request context the same way
172 // the OptionalUser middleware would.
173 func withViewer(req *http.Request, viewer middleware.CurrentUser) *http.Request {
174 return req.WithContext(middleware.WithCurrentUserForTest(req.Context(), viewer))
175 }
176
177 func TestSafeLocalPath(t *testing.T) {
178 t.Parallel()
179 tests := map[string]bool{
180 "/alice/repo": true,
181 "/alice/repo/issues/1?x=1": true,
182 "": false,
183 "alice/repo": false,
184 "//evil.example/path": false,
185 "https://evil.example": false,
186 }
187 for path, want := range tests {
188 if got := safeLocalPath(path); got != want {
189 t.Errorf("safeLocalPath(%q) = %v; want %v", path, got, want)
190 }
191 }
192 }
193
194 func TestNewRepoForm_PreselectsAllowedOrgOwnerHint(t *testing.T) {
195 t.Parallel()
196 f := newRepoFixture(t)
197 orgID := f.insertOwnedOrg(t, "tenseleyflow")
198
199 req := httptest.NewRequest(http.MethodGet, "/new?owner=tenseleyflow", nil)
200 req = withViewer(req, viewerFor(f.owner))
201 rw := httptest.NewRecorder()
202 f.handlers.newRepoForm(rw, req)
203
204 if rw.Code != http.StatusOK {
205 t.Fatalf("status %d, want 200", rw.Code)
206 }
207 want := "org:" + strconv.FormatInt(orgID, 10) + ":selected:tenseleyflow;"
208 if !strings.Contains(rw.Body.String(), want) {
209 t.Fatalf("org owner was not preselected; want %q in %s", want, rw.Body.String())
210 }
211 userSelected := "user:" + strconv.FormatInt(f.owner.ID, 10) + ":selected:alice;"
212 if strings.Contains(rw.Body.String(), userSelected) {
213 t.Fatalf("personal owner unexpectedly selected: %s", rw.Body.String())
214 }
215 }
216
217 // callLoad invokes loadRepoAndAuthorize via a test handler so we can
218 // exercise the chi URL-param plumbing the way the real router does.
219 // Returns (status, ok) — `ok` is what loadRepoAndAuthorize returned.
220 func (f *repoFixture) callLoad(t *testing.T, owner, name string, viewer middleware.CurrentUser, action policy.Action) (int, bool) {
221 t.Helper()
222 var gotOK bool
223 mux := chi.NewRouter()
224 mux.Get("/{owner}/{repo}", func(w http.ResponseWriter, r *http.Request) {
225 _, _, ok := f.handlers.loadRepoAndAuthorize(w, r, action)
226 gotOK = ok
227 if ok {
228 w.WriteHeader(http.StatusOK)
229 }
230 })
231
232 req := httptest.NewRequest(http.MethodGet, "/"+owner+"/"+name, nil)
233 req = withViewer(req, viewer)
234 rw := httptest.NewRecorder()
235 mux.ServeHTTP(rw, req)
236 return rw.Code, gotOK
237 }
238
239 func TestLookupRepoForViewer_PublicRepoVisibleToAnonymous(t *testing.T) {
240 t.Parallel()
241 f := newRepoFixture(t)
242 ctx := context.Background()
243 row, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.publicRepo.Name, anonymousViewer())
244 if err != nil {
245 t.Fatalf("public repo + anon: unexpected err %v", err)
246 }
247 if row.ID != f.publicRepo.ID {
248 t.Errorf("got repo %d; want %d", row.ID, f.publicRepo.ID)
249 }
250 }
251
252 func TestLookupRepoForViewer_PrivateRepoHiddenFromAnonymous(t *testing.T) {
253 t.Parallel()
254 f := newRepoFixture(t)
255 ctx := context.Background()
256 _, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.privateRepo.Name, anonymousViewer())
257 if !errors.Is(err, pgx.ErrNoRows) {
258 t.Fatalf("private repo + anon: want ErrNoRows (privacy-preserving), got %v", err)
259 }
260 }
261
262 func TestLookupRepoForViewer_PrivateRepoVisibleToOwner(t *testing.T) {
263 t.Parallel()
264 f := newRepoFixture(t)
265 ctx := context.Background()
266 row, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.privateRepo.Name, viewerFor(f.owner))
267 if err != nil {
268 t.Fatalf("private repo + owner: unexpected err %v", err)
269 }
270 if row.ID != f.privateRepo.ID {
271 t.Errorf("got repo %d; want %d", row.ID, f.privateRepo.ID)
272 }
273 }
274
275 func TestLookupRepoForViewer_PrivateRepoHiddenFromStranger(t *testing.T) {
276 t.Parallel()
277 f := newRepoFixture(t)
278 ctx := context.Background()
279 _, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.privateRepo.Name, viewerFor(f.stranger))
280 if !errors.Is(err, pgx.ErrNoRows) {
281 t.Fatalf("private repo + stranger: want ErrNoRows, got %v", err)
282 }
283 }
284
285 // loadRepoAndAuthorize on a private repo with an anonymous viewer
286 // returns 404, NOT 403 — leaking 403 would tell the attacker the repo
287 // exists. This is the H7 audit invariant.
288 func TestLoadRepoAndAuthorize_PrivateRepo_Anon_404(t *testing.T) {
289 t.Parallel()
290 f := newRepoFixture(t)
291 status, ok := f.callLoad(t, f.owner.Username, f.privateRepo.Name, anonymousViewer(), policy.ActionRepoAdmin)
292 if ok {
293 t.Fatal("expected ok=false")
294 }
295 if status != http.StatusNotFound {
296 t.Errorf("status: got %d; want 404 (privacy-preserving)", status)
297 }
298 }
299
300 // On a public repo, loadRepoAndAuthorize for an admin action returns
301 // an honest 403 — the viewer can see the repo exists; they just can't
302 // admin it.
303 func TestLoadRepoAndAuthorize_PublicRepo_Anon_403(t *testing.T) {
304 t.Parallel()
305 f := newRepoFixture(t)
306 status, ok := f.callLoad(t, f.owner.Username, f.publicRepo.Name, anonymousViewer(), policy.ActionRepoAdmin)
307 if ok {
308 t.Fatal("expected ok=false")
309 }
310 if status != http.StatusForbidden {
311 t.Errorf("status: got %d; want 403 (honest deny)", status)
312 }
313 }
314
315 func TestLoadRepoAndAuthorize_OwnerOnPrivate_OK(t *testing.T) {
316 t.Parallel()
317 f := newRepoFixture(t)
318 status, ok := f.callLoad(t, f.owner.Username, f.privateRepo.Name, viewerFor(f.owner), policy.ActionRepoAdmin)
319 if !ok {
320 t.Fatal("expected ok=true")
321 }
322 if status != http.StatusOK {
323 t.Errorf("status: got %d; want 200", status)
324 }
325 }
326
327 func anonymousViewer() middleware.CurrentUser {
328 return middleware.CurrentUser{}
329 }
330
331 func viewerFor(u usersdb.User) middleware.CurrentUser {
332 return middleware.CurrentUser{
333 ID: u.ID,
334 Username: u.Username,
335 }
336 }
337
338 func (f *repoFixture) insertOwnedOrg(t *testing.T, slug string) int64 {
339 t.Helper()
340 var orgID int64
341 if err := f.pool.QueryRow(context.Background(),
342 `INSERT INTO orgs (slug, display_name, created_by_user_id)
343 VALUES ($1, $2, $3)
344 RETURNING id`,
345 slug, slug, f.owner.ID).Scan(&orgID); err != nil {
346 t.Fatalf("insert org: %v", err)
347 }
348 if _, err := f.pool.Exec(context.Background(),
349 `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`,
350 orgID, f.owner.ID); err != nil {
351 t.Fatalf("insert org member: %v", err)
352 }
353 return orgID
354 }
355