Go · 12834 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/storage"
36 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
37 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
38 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
39 "github.com/tenseleyFlow/shithub/internal/web/middleware"
40 "github.com/tenseleyFlow/shithub/internal/web/render"
41 )
42
43 // fixtureHash is a static argon2 PHC test fixture (zero salt, zero key)
44 // — not a real credential. Same shape as the one used in
45 // internal/repos/create_test.go.
46 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
47 "AAAAAAAAAAAAAAAA$" +
48 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
49
50 type repoFixture struct {
51 pool *pgxpool.Pool
52 handlers *Handlers
53 objectStore storage.ObjectStore
54 owner usersdb.User
55 stranger usersdb.User
56 publicRepo reposdb.Repo
57 privateRepo reposdb.Repo
58 }
59
60 // newRepoFixture sets up a Handlers wired to a fresh test DB with two
61 // users (owner alice, stranger bob) and two repos (public + private).
62 func newRepoFixture(t *testing.T) *repoFixture {
63 t.Helper()
64 pool := dbtest.NewTestDB(t)
65
66 rfs, err := storage.NewRepoFS(t.TempDir())
67 if err != nil {
68 t.Fatalf("NewRepoFS: %v", err)
69 }
70 rr, err := render.New(minimalTemplatesFS(), render.Options{})
71 if err != nil {
72 t.Fatalf("render.New: %v", err)
73 }
74 objectStore := storage.NewMemoryStore()
75
76 h, err := New(Deps{
77 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
78 Render: rr,
79 Pool: pool,
80 RepoFS: rfs,
81 ObjectStore: objectStore,
82 Audit: audit.NewRecorder(),
83 Limiter: throttle.NewLimiter(),
84 })
85 if err != nil {
86 t.Fatalf("New: %v", err)
87 }
88
89 uq := usersdb.New()
90 rq := reposdb.New()
91 ctx := context.Background()
92
93 owner, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
94 Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
95 })
96 if err != nil {
97 t.Fatalf("CreateUser alice: %v", err)
98 }
99 stranger, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
100 Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash,
101 })
102 if err != nil {
103 t.Fatalf("CreateUser bob: %v", err)
104 }
105
106 pubRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
107 OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
108 Name: "public-repo",
109 Description: "",
110 Visibility: reposdb.RepoVisibilityPublic,
111 DefaultBranch: "trunk",
112 })
113 if err != nil {
114 t.Fatalf("CreateRepo public: %v", err)
115 }
116 privRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
117 OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
118 Name: "private-repo",
119 Description: "",
120 Visibility: reposdb.RepoVisibilityPrivate,
121 DefaultBranch: "trunk",
122 })
123 if err != nil {
124 t.Fatalf("CreateRepo private: %v", err)
125 }
126
127 return &repoFixture{
128 pool: pool,
129 handlers: h,
130 objectStore: objectStore,
131 owner: owner,
132 stranger: stranger,
133 publicRepo: pubRepo,
134 privateRepo: privRepo,
135 }
136 }
137
138 // minimalTemplatesFS returns just the error pages that
139 // loadRepoAndAuthorize needs in order to render its 404/403 responses.
140 func minimalTemplatesFS() fstest.MapFS {
141 layout := []byte(`{{ define "layout" }}{{ template "page" . }}{{ end }}`)
142 body := []byte(`{{ define "page" }}{{ .StatusText }}: {{ .Message }}{{ end }}`)
143 return fstest.MapFS{
144 "_layout.html": {Data: layout},
145 "_repo_settings_nav.html": {Data: []byte(`{{ define "repo-settings-nav" }}NAV{{ end }}`)},
146 "errors/403.html": {Data: body},
147 "errors/404.html": {Data: body},
148 "errors/429.html": {Data: body},
149 "errors/500.html": {Data: body},
150 "repo/new.html": {Data: []byte(`{{ define "page" }}OWNERS={{ range .Owners }}{{ .Token }}:{{ if eq .Token $.Form.Owner }}selected{{ end }}:{{ .Slug }};{{ end }}{{ end }}`)},
151 "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 }}`)},
152 "repo/_action_run_status.html": {Data: []byte(`{{ define "action-run-status" }}STATUS={{ .Run.StateClass }}:{{ .Run.IsTerminal }}:{{ .Run.StatusHref }};{{ end }}`)},
153 "repo/action_run.html": {Data: []byte(`{{ define "page" }}RUN={{ .Run.Title }}:#{{ .Run.RunIndex }}:{{ .Run.Event }}:{{ .Run.ActorUsername }}:{{ .Run.StateClass }};SUMMARY={{ .Run.JobCount }}:{{ .Run.CompletedCount }}:{{ .Run.FailureCount }}:{{ .Run.ArtifactCount }};{{ range .Run.Jobs }}JOB={{ .Name }}:{{ .StateClass }}:{{ .NeedsText }}:{{ .RunsOn }};{{ range .Steps }}STEP={{ .Name }}:{{ .StateClass }}:{{ .LogHref }};{{ end }}{{ end }}{{ end }}`)},
154 "repo/action_run_status.html": {Data: []byte(`{{ define "page" }}{{ template "action-run-status" . }}{{ end }}`)},
155 "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 }}`)},
156 "repo/settings_secrets.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ range .Secrets }}SECRET={{ .Name }};{{ end }}{{ range .Variables }}VAR={{ .Name }}:{{ .Value }};{{ end }}{{ end }}`)},
157 }
158 }
159
160 // withViewer attaches a CurrentUser to the request context the same way
161 // the OptionalUser middleware would.
162 func withViewer(req *http.Request, viewer middleware.CurrentUser) *http.Request {
163 return req.WithContext(middleware.WithCurrentUserForTest(req.Context(), viewer))
164 }
165
166 func TestSafeLocalPath(t *testing.T) {
167 t.Parallel()
168 tests := map[string]bool{
169 "/alice/repo": true,
170 "/alice/repo/issues/1?x=1": true,
171 "": false,
172 "alice/repo": false,
173 "//evil.example/path": false,
174 "https://evil.example": false,
175 }
176 for path, want := range tests {
177 if got := safeLocalPath(path); got != want {
178 t.Errorf("safeLocalPath(%q) = %v; want %v", path, got, want)
179 }
180 }
181 }
182
183 func TestNewRepoForm_PreselectsAllowedOrgOwnerHint(t *testing.T) {
184 t.Parallel()
185 f := newRepoFixture(t)
186 orgID := f.insertOwnedOrg(t, "tenseleyflow")
187
188 req := httptest.NewRequest(http.MethodGet, "/new?owner=tenseleyflow", nil)
189 req = withViewer(req, viewerFor(f.owner))
190 rw := httptest.NewRecorder()
191 f.handlers.newRepoForm(rw, req)
192
193 if rw.Code != http.StatusOK {
194 t.Fatalf("status %d, want 200", rw.Code)
195 }
196 want := "org:" + strconv.FormatInt(orgID, 10) + ":selected:tenseleyflow;"
197 if !strings.Contains(rw.Body.String(), want) {
198 t.Fatalf("org owner was not preselected; want %q in %s", want, rw.Body.String())
199 }
200 userSelected := "user:" + strconv.FormatInt(f.owner.ID, 10) + ":selected:alice;"
201 if strings.Contains(rw.Body.String(), userSelected) {
202 t.Fatalf("personal owner unexpectedly selected: %s", rw.Body.String())
203 }
204 }
205
206 // callLoad invokes loadRepoAndAuthorize via a test handler so we can
207 // exercise the chi URL-param plumbing the way the real router does.
208 // Returns (status, ok) — `ok` is what loadRepoAndAuthorize returned.
209 func (f *repoFixture) callLoad(t *testing.T, owner, name string, viewer middleware.CurrentUser, action policy.Action) (int, bool) {
210 t.Helper()
211 var gotOK bool
212 mux := chi.NewRouter()
213 mux.Get("/{owner}/{repo}", func(w http.ResponseWriter, r *http.Request) {
214 _, _, ok := f.handlers.loadRepoAndAuthorize(w, r, action)
215 gotOK = ok
216 if ok {
217 w.WriteHeader(http.StatusOK)
218 }
219 })
220
221 req := httptest.NewRequest(http.MethodGet, "/"+owner+"/"+name, nil)
222 req = withViewer(req, viewer)
223 rw := httptest.NewRecorder()
224 mux.ServeHTTP(rw, req)
225 return rw.Code, gotOK
226 }
227
228 func TestLookupRepoForViewer_PublicRepoVisibleToAnonymous(t *testing.T) {
229 t.Parallel()
230 f := newRepoFixture(t)
231 ctx := context.Background()
232 row, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.publicRepo.Name, anonymousViewer())
233 if err != nil {
234 t.Fatalf("public repo + anon: unexpected err %v", err)
235 }
236 if row.ID != f.publicRepo.ID {
237 t.Errorf("got repo %d; want %d", row.ID, f.publicRepo.ID)
238 }
239 }
240
241 func TestLookupRepoForViewer_PrivateRepoHiddenFromAnonymous(t *testing.T) {
242 t.Parallel()
243 f := newRepoFixture(t)
244 ctx := context.Background()
245 _, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.privateRepo.Name, anonymousViewer())
246 if !errors.Is(err, pgx.ErrNoRows) {
247 t.Fatalf("private repo + anon: want ErrNoRows (privacy-preserving), got %v", err)
248 }
249 }
250
251 func TestLookupRepoForViewer_PrivateRepoVisibleToOwner(t *testing.T) {
252 t.Parallel()
253 f := newRepoFixture(t)
254 ctx := context.Background()
255 row, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.privateRepo.Name, viewerFor(f.owner))
256 if err != nil {
257 t.Fatalf("private repo + owner: unexpected err %v", err)
258 }
259 if row.ID != f.privateRepo.ID {
260 t.Errorf("got repo %d; want %d", row.ID, f.privateRepo.ID)
261 }
262 }
263
264 func TestLookupRepoForViewer_PrivateRepoHiddenFromStranger(t *testing.T) {
265 t.Parallel()
266 f := newRepoFixture(t)
267 ctx := context.Background()
268 _, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.privateRepo.Name, viewerFor(f.stranger))
269 if !errors.Is(err, pgx.ErrNoRows) {
270 t.Fatalf("private repo + stranger: want ErrNoRows, got %v", err)
271 }
272 }
273
274 // loadRepoAndAuthorize on a private repo with an anonymous viewer
275 // returns 404, NOT 403 — leaking 403 would tell the attacker the repo
276 // exists. This is the H7 audit invariant.
277 func TestLoadRepoAndAuthorize_PrivateRepo_Anon_404(t *testing.T) {
278 t.Parallel()
279 f := newRepoFixture(t)
280 status, ok := f.callLoad(t, f.owner.Username, f.privateRepo.Name, anonymousViewer(), policy.ActionRepoAdmin)
281 if ok {
282 t.Fatal("expected ok=false")
283 }
284 if status != http.StatusNotFound {
285 t.Errorf("status: got %d; want 404 (privacy-preserving)", status)
286 }
287 }
288
289 // On a public repo, loadRepoAndAuthorize for an admin action returns
290 // an honest 403 — the viewer can see the repo exists; they just can't
291 // admin it.
292 func TestLoadRepoAndAuthorize_PublicRepo_Anon_403(t *testing.T) {
293 t.Parallel()
294 f := newRepoFixture(t)
295 status, ok := f.callLoad(t, f.owner.Username, f.publicRepo.Name, anonymousViewer(), policy.ActionRepoAdmin)
296 if ok {
297 t.Fatal("expected ok=false")
298 }
299 if status != http.StatusForbidden {
300 t.Errorf("status: got %d; want 403 (honest deny)", status)
301 }
302 }
303
304 func TestLoadRepoAndAuthorize_OwnerOnPrivate_OK(t *testing.T) {
305 t.Parallel()
306 f := newRepoFixture(t)
307 status, ok := f.callLoad(t, f.owner.Username, f.privateRepo.Name, viewerFor(f.owner), policy.ActionRepoAdmin)
308 if !ok {
309 t.Fatal("expected ok=true")
310 }
311 if status != http.StatusOK {
312 t.Errorf("status: got %d; want 200", status)
313 }
314 }
315
316 func anonymousViewer() middleware.CurrentUser {
317 return middleware.CurrentUser{}
318 }
319
320 func viewerFor(u usersdb.User) middleware.CurrentUser {
321 return middleware.CurrentUser{
322 ID: u.ID,
323 Username: u.Username,
324 }
325 }
326
327 func (f *repoFixture) insertOwnedOrg(t *testing.T, slug string) int64 {
328 t.Helper()
329 var orgID int64
330 if err := f.pool.QueryRow(context.Background(),
331 `INSERT INTO orgs (slug, display_name, created_by_user_id)
332 VALUES ($1, $2, $3)
333 RETURNING id`,
334 slug, slug, f.owner.ID).Scan(&orgID); err != nil {
335 t.Fatalf("insert org: %v", err)
336 }
337 if _, err := f.pool.Exec(context.Background(),
338 `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`,
339 orgID, f.owner.ID); err != nil {
340 t.Fatalf("insert org member: %v", err)
341 }
342 return orgID
343 }
344