tenseleyflow/shithub / 5007baa

Browse files

api: repos REST contract tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5007baa9d46534413aae73fddff45007b58dd200
Parents
e0211a8
Tree
48bfe0a

1 changed file

StatusFile+-
A internal/web/handlers/api/repos_test.go 441 0
internal/web/handlers/api/repos_test.goadded
@@ -0,0 +1,441 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api_test
4
+
5
+import (
6
+	"bytes"
7
+	"context"
8
+	"encoding/json"
9
+	"io"
10
+	"log/slog"
11
+	"net/http"
12
+	"net/http/httptest"
13
+	"strings"
14
+	"testing"
15
+
16
+	"github.com/go-chi/chi/v5"
17
+	"github.com/jackc/pgx/v5/pgtype"
18
+	"github.com/jackc/pgx/v5/pgxpool"
19
+
20
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
21
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
22
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
23
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
24
+	"github.com/tenseleyFlow/shithub/internal/ratelimit"
25
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
26
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
27
+	apih "github.com/tenseleyFlow/shithub/internal/web/handlers/api"
28
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit"
29
+)
30
+
31
+type apiRepo struct {
32
+	ID            int64  `json:"id"`
33
+	Name          string `json:"name"`
34
+	FullName      string `json:"full_name"`
35
+	OwnerLogin    string `json:"owner_login"`
36
+	OwnerType     string `json:"owner_type"`
37
+	Description   string `json:"description"`
38
+	Visibility    string `json:"visibility"`
39
+	Private       bool   `json:"private"`
40
+	DefaultBranch string `json:"default_branch"`
41
+	Fork          bool   `json:"fork"`
42
+	Archived      bool   `json:"archived"`
43
+	HasIssues     bool   `json:"has_issues"`
44
+	HasPulls      bool   `json:"has_pulls"`
45
+	StarCount     int64  `json:"star_count"`
46
+	WatcherCount  int64  `json:"watcher_count"`
47
+	ForkCount     int64  `json:"fork_count"`
48
+	CreatedAt     string `json:"created_at"`
49
+	UpdatedAt     string `json:"updated_at"`
50
+}
51
+
52
+// newReposAPIRouter builds an API router with the repo-create stack
53
+// wired in: Audit, Throttle, and a per-test RepoFS rooted at t.TempDir.
54
+// ShithubdPath is left empty so hook installation is a no-op (matches
55
+// the repos.Create test fixtures).
56
+func newReposAPIRouter(t *testing.T, pool *pgxpool.Pool) (http.Handler, *storage.RepoFS) {
57
+	t.Helper()
58
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
59
+	rfs, err := storage.NewRepoFS(t.TempDir())
60
+	if err != nil {
61
+		t.Fatalf("storage.NewRepoFS: %v", err)
62
+	}
63
+	h, err := apih.New(apih.Deps{
64
+		Pool:        pool,
65
+		Logger:      logger,
66
+		RepoFS:      rfs,
67
+		Audit:       audit.NewRecorder(),
68
+		Throttle:    throttle.NewLimiter(),
69
+		RateLimiter: ratelimit.New(pool),
70
+		BaseURL:     "https://shithub.test",
71
+		APILimit: apilimit.Config{
72
+			AuthedPerHour: 5000,
73
+			AnonPerHour:   60,
74
+			Logger:        logger,
75
+		},
76
+	})
77
+	if err != nil {
78
+		t.Fatalf("api.New: %v", err)
79
+	}
80
+	r := chi.NewRouter()
81
+	h.Mount(r)
82
+	return r, rfs
83
+}
84
+
85
+func seedRepoCreatorUser(t *testing.T, pool *pgxpool.Pool, username string) (userID int64) {
86
+	t.Helper()
87
+	ctx := context.Background()
88
+	q := usersdb.New()
89
+	user, err := q.CreateUser(ctx, pool, usersdb.CreateUserParams{
90
+		Username:     username,
91
+		DisplayName:  strings.ToUpper(username[:1]) + username[1:],
92
+		PasswordHash: runnerAPIFixtureHash,
93
+	})
94
+	if err != nil {
95
+		t.Fatalf("CreateUser: %v", err)
96
+	}
97
+	em, err := q.CreateUserEmail(ctx, pool, usersdb.CreateUserEmailParams{
98
+		UserID:    user.ID,
99
+		Email:     username + "@example.test",
100
+		IsPrimary: true,
101
+	})
102
+	if err != nil {
103
+		t.Fatalf("CreateUserEmail: %v", err)
104
+	}
105
+	if err := q.MarkUserEmailVerified(ctx, pool, em.ID); err != nil {
106
+		t.Fatalf("MarkUserEmailVerified: %v", err)
107
+	}
108
+	if err := q.LinkUserPrimaryEmail(ctx, pool, usersdb.LinkUserPrimaryEmailParams{
109
+		ID:             user.ID,
110
+		PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
111
+	}); err != nil {
112
+		t.Fatalf("LinkUserPrimaryEmail: %v", err)
113
+	}
114
+	if err := q.MarkUserEmailPrimaryVerified(ctx, pool, user.ID); err != nil {
115
+		t.Fatalf("MarkUserEmailPrimaryVerified: %v", err)
116
+	}
117
+	return user.ID
118
+}
119
+
120
+func TestRepos_CreatePersonalAndGet(t *testing.T) {
121
+	pool := dbtest.NewTestDB(t)
122
+	router, _ := newReposAPIRouter(t, pool)
123
+	userID := seedRepoCreatorUser(t, pool, "alice")
124
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoWrite))
125
+
126
+	body, _ := json.Marshal(map[string]any{
127
+		"name":        "demo",
128
+		"description": "first cut",
129
+		"visibility":  "public",
130
+	})
131
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
132
+	req.Header.Set("Authorization", "Bearer "+token)
133
+	rr := httptest.NewRecorder()
134
+	router.ServeHTTP(rr, req)
135
+	if rr.Code != http.StatusCreated {
136
+		t.Fatalf("create status: got %d, want 201; body=%s", rr.Code, rr.Body.String())
137
+	}
138
+	var created apiRepo
139
+	if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
140
+		t.Fatalf("decode create: %v; body=%s", err, rr.Body.String())
141
+	}
142
+	if created.Name != "demo" || created.OwnerLogin != "alice" || created.OwnerType != "user" {
143
+		t.Errorf("create payload: %+v", created)
144
+	}
145
+	if created.Visibility != "public" || created.Private {
146
+		t.Errorf("visibility: got %+v, want public/public", created)
147
+	}
148
+	if created.DefaultBranch != "trunk" {
149
+		t.Errorf("default_branch: got %q, want trunk", created.DefaultBranch)
150
+	}
151
+
152
+	// GET single repo.
153
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo", nil)
154
+	req.Header.Set("Authorization", "Bearer "+token)
155
+	rr = httptest.NewRecorder()
156
+	router.ServeHTTP(rr, req)
157
+	if rr.Code != http.StatusOK {
158
+		t.Fatalf("get status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
159
+	}
160
+}
161
+
162
+func TestRepos_CreateRejectsBadName(t *testing.T) {
163
+	pool := dbtest.NewTestDB(t)
164
+	router, _ := newReposAPIRouter(t, pool)
165
+	userID := seedRepoCreatorUser(t, pool, "alice")
166
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoWrite))
167
+
168
+	body, _ := json.Marshal(map[string]any{"name": "BAD..NAME", "visibility": "public"})
169
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
170
+	req.Header.Set("Authorization", "Bearer "+token)
171
+	rr := httptest.NewRecorder()
172
+	router.ServeHTTP(rr, req)
173
+	if rr.Code != http.StatusUnprocessableEntity {
174
+		t.Fatalf("status: got %d, want 422; body=%s", rr.Code, rr.Body.String())
175
+	}
176
+}
177
+
178
+func TestRepos_CreateRejectsDuplicate(t *testing.T) {
179
+	pool := dbtest.NewTestDB(t)
180
+	router, _ := newReposAPIRouter(t, pool)
181
+	userID := seedRepoCreatorUser(t, pool, "alice")
182
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoWrite))
183
+
184
+	mk := func() *httptest.ResponseRecorder {
185
+		body, _ := json.Marshal(map[string]any{"name": "demo", "visibility": "public"})
186
+		req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
187
+		req.Header.Set("Authorization", "Bearer "+token)
188
+		rr := httptest.NewRecorder()
189
+		router.ServeHTTP(rr, req)
190
+		return rr
191
+	}
192
+	if rr := mk(); rr.Code != http.StatusCreated {
193
+		t.Fatalf("first create: %d", rr.Code)
194
+	}
195
+	rr := mk()
196
+	if rr.Code != http.StatusConflict {
197
+		t.Fatalf("dup create: got %d, want 409; body=%s", rr.Code, rr.Body.String())
198
+	}
199
+}
200
+
201
+func TestRepos_CreateRequiresRepoWriteScope(t *testing.T) {
202
+	pool := dbtest.NewTestDB(t)
203
+	router, _ := newReposAPIRouter(t, pool)
204
+	userID := seedRepoCreatorUser(t, pool, "alice")
205
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoRead))
206
+
207
+	body, _ := json.Marshal(map[string]any{"name": "demo", "visibility": "public"})
208
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
209
+	req.Header.Set("Authorization", "Bearer "+token)
210
+	rr := httptest.NewRecorder()
211
+	router.ServeHTTP(rr, req)
212
+	if rr.Code != http.StatusForbidden {
213
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
214
+	}
215
+}
216
+
217
+func TestRepos_ListAuthedUserSeesPrivate(t *testing.T) {
218
+	pool := dbtest.NewTestDB(t)
219
+	router, _ := newReposAPIRouter(t, pool)
220
+	userID := seedRepoCreatorUser(t, pool, "alice")
221
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoWrite))
222
+
223
+	for _, spec := range []struct {
224
+		name, vis string
225
+	}{
226
+		{"demo-public", "public"},
227
+		{"demo-private", "private"},
228
+	} {
229
+		body, _ := json.Marshal(map[string]any{"name": spec.name, "visibility": spec.vis})
230
+		req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
231
+		req.Header.Set("Authorization", "Bearer "+token)
232
+		rr := httptest.NewRecorder()
233
+		router.ServeHTTP(rr, req)
234
+		if rr.Code != http.StatusCreated {
235
+			t.Fatalf("seed %s: %d; body=%s", spec.name, rr.Code, rr.Body.String())
236
+		}
237
+	}
238
+
239
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/user/repos", nil)
240
+	req.Header.Set("Authorization", "Bearer "+token)
241
+	rr := httptest.NewRecorder()
242
+	router.ServeHTTP(rr, req)
243
+	if rr.Code != http.StatusOK {
244
+		t.Fatalf("list status: got %d, want 200", rr.Code)
245
+	}
246
+	var listed []apiRepo
247
+	if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
248
+		t.Fatalf("decode: %v", err)
249
+	}
250
+	if len(listed) != 2 {
251
+		t.Fatalf("count: got %d, want 2; %+v", len(listed), listed)
252
+	}
253
+}
254
+
255
+func TestRepos_ListOtherUserPublicOnly(t *testing.T) {
256
+	pool := dbtest.NewTestDB(t)
257
+	router, _ := newReposAPIRouter(t, pool)
258
+	aliceID := seedRepoCreatorUser(t, pool, "alice")
259
+	bobID := seedRepoCreatorUser(t, pool, "bob")
260
+	tokenAlice := mintRunnerAPIPAT(t, pool, aliceID, string(pat.ScopeRepoWrite))
261
+	tokenBob := mintRunnerAPIPAT(t, pool, bobID, string(pat.ScopeRepoRead))
262
+
263
+	// Alice creates one public + one private.
264
+	for _, spec := range []struct{ name, vis string }{
265
+		{"public-one", "public"},
266
+		{"private-one", "private"},
267
+	} {
268
+		body, _ := json.Marshal(map[string]any{"name": spec.name, "visibility": spec.vis})
269
+		req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
270
+		req.Header.Set("Authorization", "Bearer "+tokenAlice)
271
+		rr := httptest.NewRecorder()
272
+		router.ServeHTTP(rr, req)
273
+		if rr.Code != http.StatusCreated {
274
+			t.Fatalf("seed %s: %d", spec.name, rr.Code)
275
+		}
276
+	}
277
+
278
+	// Bob lists alice's repos — should see only the public one.
279
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/users/alice/repos", nil)
280
+	req.Header.Set("Authorization", "Bearer "+tokenBob)
281
+	rr := httptest.NewRecorder()
282
+	router.ServeHTTP(rr, req)
283
+	if rr.Code != http.StatusOK {
284
+		t.Fatalf("status: got %d, want 200", rr.Code)
285
+	}
286
+	var listed []apiRepo
287
+	if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
288
+		t.Fatalf("decode: %v", err)
289
+	}
290
+	if len(listed) != 1 || listed[0].Name != "public-one" {
291
+		t.Fatalf("public-only filter failed: %+v", listed)
292
+	}
293
+}
294
+
295
+func TestRepos_GetPrivateHidesFromOthers(t *testing.T) {
296
+	pool := dbtest.NewTestDB(t)
297
+	router, _ := newReposAPIRouter(t, pool)
298
+	aliceID := seedRepoCreatorUser(t, pool, "alice")
299
+	bobID := seedRepoCreatorUser(t, pool, "bob")
300
+	tokenAlice := mintRunnerAPIPAT(t, pool, aliceID, string(pat.ScopeRepoWrite))
301
+	tokenBob := mintRunnerAPIPAT(t, pool, bobID, string(pat.ScopeRepoRead))
302
+
303
+	body, _ := json.Marshal(map[string]any{"name": "secret", "visibility": "private"})
304
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
305
+	req.Header.Set("Authorization", "Bearer "+tokenAlice)
306
+	rr := httptest.NewRecorder()
307
+	router.ServeHTTP(rr, req)
308
+	if rr.Code != http.StatusCreated {
309
+		t.Fatalf("seed: %d; %s", rr.Code, rr.Body.String())
310
+	}
311
+
312
+	// Bob asks for the private repo directly — must 404 (existence leak).
313
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/secret", nil)
314
+	req.Header.Set("Authorization", "Bearer "+tokenBob)
315
+	rr = httptest.NewRecorder()
316
+	router.ServeHTTP(rr, req)
317
+	if rr.Code != http.StatusNotFound {
318
+		t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String())
319
+	}
320
+}
321
+
322
+func TestRepos_PatchDescriptionAndVisibility(t *testing.T) {
323
+	pool := dbtest.NewTestDB(t)
324
+	router, _ := newReposAPIRouter(t, pool)
325
+	userID := seedRepoCreatorUser(t, pool, "alice")
326
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoWrite))
327
+
328
+	body, _ := json.Marshal(map[string]any{"name": "demo", "visibility": "public", "description": "old"})
329
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
330
+	req.Header.Set("Authorization", "Bearer "+token)
331
+	rr := httptest.NewRecorder()
332
+	router.ServeHTTP(rr, req)
333
+	if rr.Code != http.StatusCreated {
334
+		t.Fatalf("seed: %d", rr.Code)
335
+	}
336
+
337
+	patch, _ := json.Marshal(map[string]any{"description": "new", "visibility": "private"})
338
+	req = httptest.NewRequest(http.MethodPatch, "/api/v1/repos/alice/demo", bytes.NewReader(patch))
339
+	req.Header.Set("Authorization", "Bearer "+token)
340
+	rr = httptest.NewRecorder()
341
+	router.ServeHTTP(rr, req)
342
+	if rr.Code != http.StatusOK {
343
+		t.Fatalf("patch status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
344
+	}
345
+	var updated apiRepo
346
+	if err := json.Unmarshal(rr.Body.Bytes(), &updated); err != nil {
347
+		t.Fatalf("decode patch: %v", err)
348
+	}
349
+	if updated.Description != "new" {
350
+		t.Errorf("description: got %q, want %q", updated.Description, "new")
351
+	}
352
+	if updated.Visibility != "private" || !updated.Private {
353
+		t.Errorf("visibility: got %+v", updated)
354
+	}
355
+}
356
+
357
+func TestRepos_PatchRejectsNonOwner(t *testing.T) {
358
+	pool := dbtest.NewTestDB(t)
359
+	router, _ := newReposAPIRouter(t, pool)
360
+	aliceID := seedRepoCreatorUser(t, pool, "alice")
361
+	bobID := seedRepoCreatorUser(t, pool, "bob")
362
+	tokenAlice := mintRunnerAPIPAT(t, pool, aliceID, string(pat.ScopeRepoWrite))
363
+	tokenBob := mintRunnerAPIPAT(t, pool, bobID, string(pat.ScopeRepoWrite))
364
+
365
+	body, _ := json.Marshal(map[string]any{"name": "demo", "visibility": "public"})
366
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
367
+	req.Header.Set("Authorization", "Bearer "+tokenAlice)
368
+	rr := httptest.NewRecorder()
369
+	router.ServeHTTP(rr, req)
370
+	if rr.Code != http.StatusCreated {
371
+		t.Fatalf("seed: %d", rr.Code)
372
+	}
373
+
374
+	patch, _ := json.Marshal(map[string]any{"description": "evil"})
375
+	req = httptest.NewRequest(http.MethodPatch, "/api/v1/repos/alice/demo", bytes.NewReader(patch))
376
+	req.Header.Set("Authorization", "Bearer "+tokenBob)
377
+	rr = httptest.NewRecorder()
378
+	router.ServeHTTP(rr, req)
379
+	if rr.Code != http.StatusNotFound {
380
+		t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String())
381
+	}
382
+}
383
+
384
+func TestRepos_DeleteSoftDeletes(t *testing.T) {
385
+	pool := dbtest.NewTestDB(t)
386
+	router, _ := newReposAPIRouter(t, pool)
387
+	userID := seedRepoCreatorUser(t, pool, "alice")
388
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoWrite))
389
+
390
+	body, _ := json.Marshal(map[string]any{"name": "throwaway", "visibility": "public"})
391
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
392
+	req.Header.Set("Authorization", "Bearer "+token)
393
+	rr := httptest.NewRecorder()
394
+	router.ServeHTTP(rr, req)
395
+	if rr.Code != http.StatusCreated {
396
+		t.Fatalf("seed: %d", rr.Code)
397
+	}
398
+
399
+	req = httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/throwaway", nil)
400
+	req.Header.Set("Authorization", "Bearer "+token)
401
+	rr = httptest.NewRecorder()
402
+	router.ServeHTTP(rr, req)
403
+	if rr.Code != http.StatusNoContent {
404
+		t.Fatalf("delete status: got %d, want 204; body=%s", rr.Code, rr.Body.String())
405
+	}
406
+
407
+	// Subsequent GET 404s.
408
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/throwaway", nil)
409
+	req.Header.Set("Authorization", "Bearer "+token)
410
+	rr = httptest.NewRecorder()
411
+	router.ServeHTTP(rr, req)
412
+	if rr.Code != http.StatusNotFound {
413
+		t.Fatalf("post-delete get: got %d, want 404; body=%s", rr.Code, rr.Body.String())
414
+	}
415
+}
416
+
417
+func TestRepos_DeleteOnlyOwners(t *testing.T) {
418
+	pool := dbtest.NewTestDB(t)
419
+	router, _ := newReposAPIRouter(t, pool)
420
+	aliceID := seedRepoCreatorUser(t, pool, "alice")
421
+	bobID := seedRepoCreatorUser(t, pool, "bob")
422
+	tokenAlice := mintRunnerAPIPAT(t, pool, aliceID, string(pat.ScopeRepoWrite))
423
+	tokenBob := mintRunnerAPIPAT(t, pool, bobID, string(pat.ScopeRepoWrite))
424
+
425
+	body, _ := json.Marshal(map[string]any{"name": "demo", "visibility": "public"})
426
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/user/repos", bytes.NewReader(body))
427
+	req.Header.Set("Authorization", "Bearer "+tokenAlice)
428
+	rr := httptest.NewRecorder()
429
+	router.ServeHTTP(rr, req)
430
+	if rr.Code != http.StatusCreated {
431
+		t.Fatalf("seed: %d", rr.Code)
432
+	}
433
+
434
+	req = httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo", nil)
435
+	req.Header.Set("Authorization", "Bearer "+tokenBob)
436
+	rr = httptest.NewRecorder()
437
+	router.ServeHTTP(rr, req)
438
+	if rr.Code != http.StatusNotFound {
439
+		t.Fatalf("cross-user delete: got %d, want 404; body=%s", rr.Code, rr.Body.String())
440
+	}
441
+}