tenseleyflow/shithub / 906a357

Browse files

S39: repo handler smoke tests — visibility + 2-pass authorization

Authored by espadonne
SHA
906a357e2033b4dc91619315febe3c607b9e6b99
Parents
d80d1da
Tree
0ca3d89

1 changed file

StatusFile+-
A internal/web/handlers/repo/repo_test.go 271 0
internal/web/handlers/repo/repo_test.goadded
@@ -0,0 +1,271 @@
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
+	"testing"
23
+	"testing/fstest"
24
+
25
+	"github.com/go-chi/chi/v5"
26
+	"github.com/jackc/pgx/v5"
27
+	"github.com/jackc/pgx/v5/pgtype"
28
+	"github.com/jackc/pgx/v5/pgxpool"
29
+
30
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
31
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
32
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
33
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
34
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
35
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
36
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
37
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
38
+	"github.com/tenseleyFlow/shithub/internal/web/render"
39
+)
40
+
41
+// fixtureHash is a static argon2 PHC test fixture (zero salt, zero key)
42
+// — not a real credential. Same shape as the one used in
43
+// internal/repos/create_test.go.
44
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
45
+	"AAAAAAAAAAAAAAAA$" +
46
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
47
+
48
+type repoFixture struct {
49
+	pool        *pgxpool.Pool
50
+	handlers    *Handlers
51
+	owner       usersdb.User
52
+	stranger    usersdb.User
53
+	publicRepo  reposdb.Repo
54
+	privateRepo reposdb.Repo
55
+}
56
+
57
+// newRepoFixture sets up a Handlers wired to a fresh test DB with two
58
+// users (owner alice, stranger bob) and two repos (public + private).
59
+func newRepoFixture(t *testing.T) *repoFixture {
60
+	t.Helper()
61
+	pool := dbtest.NewTestDB(t)
62
+
63
+	rfs, err := storage.NewRepoFS(t.TempDir())
64
+	if err != nil {
65
+		t.Fatalf("NewRepoFS: %v", err)
66
+	}
67
+	rr, err := render.New(minimalTemplatesFS(), render.Options{})
68
+	if err != nil {
69
+		t.Fatalf("render.New: %v", err)
70
+	}
71
+
72
+	h, err := New(Deps{
73
+		Logger:  slog.New(slog.NewTextHandler(io.Discard, nil)),
74
+		Render:  rr,
75
+		Pool:    pool,
76
+		RepoFS:  rfs,
77
+		Audit:   audit.NewRecorder(),
78
+		Limiter: throttle.NewLimiter(),
79
+	})
80
+	if err != nil {
81
+		t.Fatalf("New: %v", err)
82
+	}
83
+
84
+	uq := usersdb.New()
85
+	rq := reposdb.New()
86
+	ctx := context.Background()
87
+
88
+	owner, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
89
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
90
+	})
91
+	if err != nil {
92
+		t.Fatalf("CreateUser alice: %v", err)
93
+	}
94
+	stranger, err := uq.CreateUser(ctx, pool, usersdb.CreateUserParams{
95
+		Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash,
96
+	})
97
+	if err != nil {
98
+		t.Fatalf("CreateUser bob: %v", err)
99
+	}
100
+
101
+	pubRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
102
+		OwnerUserID:   pgtype.Int8{Int64: owner.ID, Valid: true},
103
+		Name:          "public-repo",
104
+		Description:   "",
105
+		Visibility:    reposdb.RepoVisibilityPublic,
106
+		DefaultBranch: "trunk",
107
+	})
108
+	if err != nil {
109
+		t.Fatalf("CreateRepo public: %v", err)
110
+	}
111
+	privRepo, err := rq.CreateRepo(ctx, pool, reposdb.CreateRepoParams{
112
+		OwnerUserID:   pgtype.Int8{Int64: owner.ID, Valid: true},
113
+		Name:          "private-repo",
114
+		Description:   "",
115
+		Visibility:    reposdb.RepoVisibilityPrivate,
116
+		DefaultBranch: "trunk",
117
+	})
118
+	if err != nil {
119
+		t.Fatalf("CreateRepo private: %v", err)
120
+	}
121
+
122
+	return &repoFixture{
123
+		pool:        pool,
124
+		handlers:    h,
125
+		owner:       owner,
126
+		stranger:    stranger,
127
+		publicRepo:  pubRepo,
128
+		privateRepo: privRepo,
129
+	}
130
+}
131
+
132
+// minimalTemplatesFS returns just the error pages that
133
+// loadRepoAndAuthorize needs in order to render its 404/403 responses.
134
+func minimalTemplatesFS() fstest.MapFS {
135
+	layout := []byte(`{{ define "layout" }}{{ template "page" . }}{{ end }}`)
136
+	body := []byte(`{{ define "page" }}{{ .StatusText }}: {{ .Message }}{{ end }}`)
137
+	return fstest.MapFS{
138
+		"_layout.html":    {Data: layout},
139
+		"errors/403.html": {Data: body},
140
+		"errors/404.html": {Data: body},
141
+		"errors/429.html": {Data: body},
142
+		"errors/500.html": {Data: body},
143
+	}
144
+}
145
+
146
+// withViewer attaches a CurrentUser to the request context the same way
147
+// the OptionalUser middleware would.
148
+func withViewer(req *http.Request, viewer middleware.CurrentUser) *http.Request {
149
+	return req.WithContext(middleware.WithCurrentUserForTest(req.Context(), viewer))
150
+}
151
+
152
+// callLoad invokes loadRepoAndAuthorize via a test handler so we can
153
+// exercise the chi URL-param plumbing the way the real router does.
154
+// Returns (status, ok) — `ok` is what loadRepoAndAuthorize returned.
155
+func (f *repoFixture) callLoad(t *testing.T, owner, name string, viewer middleware.CurrentUser, action policy.Action) (int, bool) {
156
+	t.Helper()
157
+	var gotOK bool
158
+	mux := chi.NewRouter()
159
+	mux.Get("/{owner}/{repo}", func(w http.ResponseWriter, r *http.Request) {
160
+		_, _, ok := f.handlers.loadRepoAndAuthorize(w, r, action)
161
+		gotOK = ok
162
+		if ok {
163
+			w.WriteHeader(http.StatusOK)
164
+		}
165
+	})
166
+
167
+	req := httptest.NewRequest(http.MethodGet, "/"+owner+"/"+name, nil)
168
+	req = withViewer(req, viewer)
169
+	rw := httptest.NewRecorder()
170
+	mux.ServeHTTP(rw, req)
171
+	return rw.Code, gotOK
172
+}
173
+
174
+func TestLookupRepoForViewer_PublicRepoVisibleToAnonymous(t *testing.T) {
175
+	t.Parallel()
176
+	f := newRepoFixture(t)
177
+	ctx := context.Background()
178
+	row, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.publicRepo.Name, anonymousViewer())
179
+	if err != nil {
180
+		t.Fatalf("public repo + anon: unexpected err %v", err)
181
+	}
182
+	if row.ID != f.publicRepo.ID {
183
+		t.Errorf("got repo %d; want %d", row.ID, f.publicRepo.ID)
184
+	}
185
+}
186
+
187
+func TestLookupRepoForViewer_PrivateRepoHiddenFromAnonymous(t *testing.T) {
188
+	t.Parallel()
189
+	f := newRepoFixture(t)
190
+	ctx := context.Background()
191
+	_, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.privateRepo.Name, anonymousViewer())
192
+	if !errors.Is(err, pgx.ErrNoRows) {
193
+		t.Fatalf("private repo + anon: want ErrNoRows (privacy-preserving), got %v", err)
194
+	}
195
+}
196
+
197
+func TestLookupRepoForViewer_PrivateRepoVisibleToOwner(t *testing.T) {
198
+	t.Parallel()
199
+	f := newRepoFixture(t)
200
+	ctx := context.Background()
201
+	row, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.privateRepo.Name, viewerFor(f.owner))
202
+	if err != nil {
203
+		t.Fatalf("private repo + owner: unexpected err %v", err)
204
+	}
205
+	if row.ID != f.privateRepo.ID {
206
+		t.Errorf("got repo %d; want %d", row.ID, f.privateRepo.ID)
207
+	}
208
+}
209
+
210
+func TestLookupRepoForViewer_PrivateRepoHiddenFromStranger(t *testing.T) {
211
+	t.Parallel()
212
+	f := newRepoFixture(t)
213
+	ctx := context.Background()
214
+	_, err := f.handlers.lookupRepoForViewer(ctx, f.owner.Username, f.privateRepo.Name, viewerFor(f.stranger))
215
+	if !errors.Is(err, pgx.ErrNoRows) {
216
+		t.Fatalf("private repo + stranger: want ErrNoRows, got %v", err)
217
+	}
218
+}
219
+
220
+// loadRepoAndAuthorize on a private repo with an anonymous viewer
221
+// returns 404, NOT 403 — leaking 403 would tell the attacker the repo
222
+// exists. This is the H7 audit invariant.
223
+func TestLoadRepoAndAuthorize_PrivateRepo_Anon_404(t *testing.T) {
224
+	t.Parallel()
225
+	f := newRepoFixture(t)
226
+	status, ok := f.callLoad(t, f.owner.Username, f.privateRepo.Name, anonymousViewer(), policy.ActionRepoAdmin)
227
+	if ok {
228
+		t.Fatal("expected ok=false")
229
+	}
230
+	if status != http.StatusNotFound {
231
+		t.Errorf("status: got %d; want 404 (privacy-preserving)", status)
232
+	}
233
+}
234
+
235
+// On a public repo, loadRepoAndAuthorize for an admin action returns
236
+// an honest 403 — the viewer can see the repo exists; they just can't
237
+// admin it.
238
+func TestLoadRepoAndAuthorize_PublicRepo_Anon_403(t *testing.T) {
239
+	t.Parallel()
240
+	f := newRepoFixture(t)
241
+	status, ok := f.callLoad(t, f.owner.Username, f.publicRepo.Name, anonymousViewer(), policy.ActionRepoAdmin)
242
+	if ok {
243
+		t.Fatal("expected ok=false")
244
+	}
245
+	if status != http.StatusForbidden {
246
+		t.Errorf("status: got %d; want 403 (honest deny)", status)
247
+	}
248
+}
249
+
250
+func TestLoadRepoAndAuthorize_OwnerOnPrivate_OK(t *testing.T) {
251
+	t.Parallel()
252
+	f := newRepoFixture(t)
253
+	status, ok := f.callLoad(t, f.owner.Username, f.privateRepo.Name, viewerFor(f.owner), policy.ActionRepoAdmin)
254
+	if !ok {
255
+		t.Fatal("expected ok=true")
256
+	}
257
+	if status != http.StatusOK {
258
+		t.Errorf("status: got %d; want 200", status)
259
+	}
260
+}
261
+
262
+func anonymousViewer() middleware.CurrentUser {
263
+	return middleware.CurrentUser{}
264
+}
265
+
266
+func viewerFor(u usersdb.User) middleware.CurrentUser {
267
+	return middleware.CurrentUser{
268
+		ID:       u.ID,
269
+		Username: u.Username,
270
+	}
271
+}