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