tenseleyflow/shithub / 31f27b2

Browse files

api: issues + labels REST contract tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
31f27b2b6144a60bfb02ba089f52d4fb6d1775da
Parents
47c7bba
Tree
0a7ae61

3 changed files

StatusFile+-
M internal/web/handlers/api/issues.go 8 12
A internal/web/handlers/api/issues_test.go 386 0
A internal/web/handlers/api/labels_test.go 163 0
internal/web/handlers/api/issues.gomodified
@@ -304,22 +304,18 @@ func (h *Handlers) issuePatch(w http.ResponseWriter, r *http.Request) {
304304
 		return
305305
 	}
306306
 
307
-	// Title/body: author OR repo collaborator with at least
308
-	// triage-equivalent permissions can edit. We gate via
309
-	// ActionIssueComment since it matches the "trusted contributor"
310
-	// archetype (comment + edit-own-issue privileges).
307
+	// Title/body: only the author or a repo collaborator with write
308
+	// access can edit. We deliberately gate via ActionRepoWrite (not
309
+	// ActionIssueComment) — comment-create is open to any logged-in
310
+	// reader on a public repo, but editing someone else's issue is a
311
+	// moderation action.
311312
 	if body.Title != nil || body.Body != nil {
312
-		// Only the author (or someone with comment-equivalent
313
-		// privileges on the repo) edits.
314
-		canEdit := false
315
-		if issue.AuthorUserID.Valid && issue.AuthorUserID.Int64 == auth.UserID {
316
-			canEdit = true
317
-		}
313
+		canEdit := issue.AuthorUserID.Valid && issue.AuthorUserID.Int64 == auth.UserID
318314
 		if !canEdit {
319
-			canEdit = policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionIssueComment, policy.NewRepoRefFromRepo(*repo)).Allow
315
+			canEdit = policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(*repo)).Allow
320316
 		}
321317
 		if !canEdit {
322
-			writeAPIError(w, http.StatusForbidden, "only the author or a collaborator may edit this issue")
318
+			writeAPIError(w, http.StatusForbidden, "only the author or a repo collaborator may edit this issue")
323319
 			return
324320
 		}
325321
 		updated, err := issues.Edit(r.Context(), h.issuesDeps(), issues.EditParams{
internal/web/handlers/api/issues_test.goadded
@@ -0,0 +1,386 @@
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
+	"fmt"
10
+	"net/http"
11
+	"net/http/httptest"
12
+	"strings"
13
+	"testing"
14
+
15
+	"github.com/jackc/pgx/v5/pgtype"
16
+	"github.com/jackc/pgx/v5/pgxpool"
17
+
18
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
19
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
20
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
21
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
22
+	"github.com/tenseleyFlow/shithub/internal/repos"
23
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
24
+)
25
+
26
+type apiIssue struct {
27
+	ID          int64    `json:"id"`
28
+	Number      int64    `json:"number"`
29
+	Title       string   `json:"title"`
30
+	Body        string   `json:"body"`
31
+	State       string   `json:"state"`
32
+	StateReason string   `json:"state_reason"`
33
+	Locked      bool     `json:"locked"`
34
+	LockReason  string   `json:"lock_reason"`
35
+	AuthorID    int64    `json:"author_id"`
36
+	Labels      []string `json:"labels"`
37
+	CreatedAt   string   `json:"created_at"`
38
+	UpdatedAt   string   `json:"updated_at"`
39
+	ClosedAt    string   `json:"closed_at"`
40
+}
41
+
42
+type apiComment struct {
43
+	ID        int64  `json:"id"`
44
+	IssueID   int64  `json:"issue_id"`
45
+	AuthorID  int64  `json:"author_id"`
46
+	Body      string `json:"body"`
47
+	CreatedAt string `json:"created_at"`
48
+	UpdatedAt string `json:"updated_at"`
49
+}
50
+
51
+// seedIssuesEnv stands up a one-shot test environment: pool + router +
52
+// owner user + a public repo (`alice/demo` by default) + a PAT scoped
53
+// to repo:write. Tests that need a second actor (Bob) build him with
54
+// seedRepoCreatorUser against the returned pool.
55
+func seedIssuesEnv(t *testing.T, ownerUsername string) (pool *pgxpool.Pool, router http.Handler, userID, repoID int64, token string) {
56
+	t.Helper()
57
+	pool = dbtest.NewTestDB(t)
58
+	router, rfs := newReposAPIRouter(t, pool)
59
+	userID = seedRepoCreatorUser(t, pool, ownerUsername)
60
+	token = mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoWrite))
61
+
62
+	res, err := repos.Create(context.Background(), repos.Deps{
63
+		Pool:    pool,
64
+		RepoFS:  rfs,
65
+		Audit:   audit.NewRecorder(),
66
+		Limiter: throttle.NewLimiter(),
67
+	}, repos.Params{
68
+		ActorUserID:   userID,
69
+		OwnerUserID:   userID,
70
+		OwnerUsername: ownerUsername,
71
+		Name:          "demo",
72
+		Description:   "demo repo",
73
+		Visibility:    "public",
74
+	})
75
+	if err != nil {
76
+		t.Fatalf("repos.Create: %v", err)
77
+	}
78
+	return pool, router, userID, res.Repo.ID, token
79
+}
80
+
81
+func TestIssues_CreateAndGet(t *testing.T) {
82
+	_, router, _, _, token := seedIssuesEnv(t, "alice")
83
+
84
+	body, _ := json.Marshal(map[string]any{"title": "first bug", "body": "kaboom"})
85
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues", bytes.NewReader(body))
86
+	req.Header.Set("Authorization", "Bearer "+token)
87
+	rr := httptest.NewRecorder()
88
+	router.ServeHTTP(rr, req)
89
+	if rr.Code != http.StatusCreated {
90
+		t.Fatalf("create status: got %d, want 201; body=%s", rr.Code, rr.Body.String())
91
+	}
92
+	var created apiIssue
93
+	if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
94
+		t.Fatalf("decode: %v", err)
95
+	}
96
+	if created.Number != 1 || created.Title != "first bug" || created.State != "open" {
97
+		t.Errorf("shape: %+v", created)
98
+	}
99
+
100
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/issues/1", nil)
101
+	req.Header.Set("Authorization", "Bearer "+token)
102
+	rr = httptest.NewRecorder()
103
+	router.ServeHTTP(rr, req)
104
+	if rr.Code != http.StatusOK {
105
+		t.Fatalf("get status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
106
+	}
107
+}
108
+
109
+func TestIssues_CreateRejectsEmptyTitle(t *testing.T) {
110
+	_, router, _, _, token := seedIssuesEnv(t, "alice")
111
+
112
+	body, _ := json.Marshal(map[string]any{"title": "", "body": "x"})
113
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues", bytes.NewReader(body))
114
+	req.Header.Set("Authorization", "Bearer "+token)
115
+	rr := httptest.NewRecorder()
116
+	router.ServeHTTP(rr, req)
117
+	if rr.Code != http.StatusUnprocessableEntity {
118
+		t.Fatalf("status: got %d, want 422; body=%s", rr.Code, rr.Body.String())
119
+	}
120
+}
121
+
122
+func TestIssues_CreateRequiresRepoWriteScope(t *testing.T) {
123
+	pool, router, userID, _, _ := seedIssuesEnv(t, "alice")
124
+	readOnly := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoRead))
125
+
126
+	body, _ := json.Marshal(map[string]any{"title": "hi"})
127
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues", bytes.NewReader(body))
128
+	req.Header.Set("Authorization", "Bearer "+readOnly)
129
+	rr := httptest.NewRecorder()
130
+	router.ServeHTTP(rr, req)
131
+	if rr.Code != http.StatusForbidden {
132
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
133
+	}
134
+}
135
+
136
+func TestIssues_ListFiltersByState(t *testing.T) {
137
+	pool, router, userID, repoID, token := seedIssuesEnv(t, "alice")
138
+	// Create two issues, close the second directly via sqlc.
139
+	for i, title := range []string{"open one", "closed one"} {
140
+		body, _ := json.Marshal(map[string]any{"title": title})
141
+		req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues", bytes.NewReader(body))
142
+		req.Header.Set("Authorization", "Bearer "+token)
143
+		rr := httptest.NewRecorder()
144
+		router.ServeHTTP(rr, req)
145
+		if rr.Code != http.StatusCreated {
146
+			t.Fatalf("seed %d: %d", i, rr.Code)
147
+		}
148
+	}
149
+	q := issuesdb.New()
150
+	issue, err := q.GetIssueByNumber(context.Background(), pool, issuesdb.GetIssueByNumberParams{
151
+		RepoID: repoID, Number: 2,
152
+	})
153
+	if err != nil {
154
+		t.Fatalf("GetIssueByNumber: %v", err)
155
+	}
156
+	if err := q.SetIssueState(context.Background(), pool, issuesdb.SetIssueStateParams{
157
+		ID:             issue.ID,
158
+		State:          issuesdb.IssueStateClosed,
159
+		StateReason:    issuesdb.NullIssueStateReason{Valid: false},
160
+		ClosedByUserID: pgtype.Int8{Int64: userID, Valid: true},
161
+	}); err != nil {
162
+		t.Fatalf("SetIssueState: %v", err)
163
+	}
164
+
165
+	for _, tc := range []struct {
166
+		state string
167
+		want  int
168
+	}{
169
+		{"open", 1},
170
+		{"closed", 1},
171
+		{"all", 2},
172
+		{"", 2},
173
+	} {
174
+		url := "/api/v1/repos/alice/demo/issues"
175
+		if tc.state != "" {
176
+			url += "?state=" + tc.state
177
+		}
178
+		req := httptest.NewRequest(http.MethodGet, url, nil)
179
+		req.Header.Set("Authorization", "Bearer "+token)
180
+		rr := httptest.NewRecorder()
181
+		router.ServeHTTP(rr, req)
182
+		if rr.Code != http.StatusOK {
183
+			t.Fatalf("state=%q: %d; body=%s", tc.state, rr.Code, rr.Body.String())
184
+		}
185
+		var listed []apiIssue
186
+		if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
187
+			t.Fatalf("decode: %v", err)
188
+		}
189
+		if len(listed) != tc.want {
190
+			t.Errorf("state=%q count: got %d, want %d", tc.state, len(listed), tc.want)
191
+		}
192
+	}
193
+}
194
+
195
+func TestIssues_PatchTitleBodyState(t *testing.T) {
196
+	_, router, _, _, token := seedIssuesEnv(t, "alice")
197
+	body, _ := json.Marshal(map[string]any{"title": "old", "body": "old body"})
198
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues", bytes.NewReader(body))
199
+	req.Header.Set("Authorization", "Bearer "+token)
200
+	rr := httptest.NewRecorder()
201
+	router.ServeHTTP(rr, req)
202
+	if rr.Code != http.StatusCreated {
203
+		t.Fatalf("seed: %d", rr.Code)
204
+	}
205
+
206
+	patch, _ := json.Marshal(map[string]any{"title": "new", "body": "new body", "state": "closed", "state_reason": "completed"})
207
+	req = httptest.NewRequest(http.MethodPatch, "/api/v1/repos/alice/demo/issues/1", bytes.NewReader(patch))
208
+	req.Header.Set("Authorization", "Bearer "+token)
209
+	rr = httptest.NewRecorder()
210
+	router.ServeHTTP(rr, req)
211
+	if rr.Code != http.StatusOK {
212
+		t.Fatalf("patch status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
213
+	}
214
+	var updated apiIssue
215
+	if err := json.Unmarshal(rr.Body.Bytes(), &updated); err != nil {
216
+		t.Fatalf("decode: %v", err)
217
+	}
218
+	if updated.Title != "new" || updated.Body != "new body" {
219
+		t.Errorf("title/body: %+v", updated)
220
+	}
221
+	if updated.State != "closed" || updated.StateReason != "completed" {
222
+		t.Errorf("state: %+v", updated)
223
+	}
224
+}
225
+
226
+func TestIssues_PatchTitleByOtherForbidden(t *testing.T) {
227
+	pool, router, _, _, tokenAlice := seedIssuesEnv(t, "alice")
228
+	body, _ := json.Marshal(map[string]any{"title": "alice's bug"})
229
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues", bytes.NewReader(body))
230
+	req.Header.Set("Authorization", "Bearer "+tokenAlice)
231
+	rr := httptest.NewRecorder()
232
+	router.ServeHTTP(rr, req)
233
+	if rr.Code != http.StatusCreated {
234
+		t.Fatalf("seed: %d", rr.Code)
235
+	}
236
+
237
+	bobID := seedRepoCreatorUser(t, pool, "bob")
238
+	tokenBob := mintRunnerAPIPAT(t, pool, bobID, string(pat.ScopeRepoWrite))
239
+
240
+	patch, _ := json.Marshal(map[string]any{"title": "hijack"})
241
+	req = httptest.NewRequest(http.MethodPatch, "/api/v1/repos/alice/demo/issues/1", bytes.NewReader(patch))
242
+	req.Header.Set("Authorization", "Bearer "+tokenBob)
243
+	rr = httptest.NewRecorder()
244
+	router.ServeHTTP(rr, req)
245
+	if rr.Code != http.StatusForbidden {
246
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
247
+	}
248
+}
249
+
250
+func TestIssues_CommentsCRUD(t *testing.T) {
251
+	_, router, _, _, token := seedIssuesEnv(t, "alice")
252
+	body, _ := json.Marshal(map[string]any{"title": "needs feedback"})
253
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues", bytes.NewReader(body))
254
+	req.Header.Set("Authorization", "Bearer "+token)
255
+	rr := httptest.NewRecorder()
256
+	router.ServeHTTP(rr, req)
257
+	if rr.Code != http.StatusCreated {
258
+		t.Fatalf("issue seed: %d", rr.Code)
259
+	}
260
+
261
+	body, _ = json.Marshal(map[string]any{"body": "lgtm"})
262
+	req = httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues/1/comments", bytes.NewReader(body))
263
+	req.Header.Set("Authorization", "Bearer "+token)
264
+	rr = httptest.NewRecorder()
265
+	router.ServeHTTP(rr, req)
266
+	if rr.Code != http.StatusCreated {
267
+		t.Fatalf("comment create: %d; body=%s", rr.Code, rr.Body.String())
268
+	}
269
+	var created apiComment
270
+	if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
271
+		t.Fatalf("decode: %v", err)
272
+	}
273
+	if created.Body != "lgtm" || created.IssueID == 0 {
274
+		t.Errorf("shape: %+v", created)
275
+	}
276
+
277
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/issues/1/comments", nil)
278
+	req.Header.Set("Authorization", "Bearer "+token)
279
+	rr = httptest.NewRecorder()
280
+	router.ServeHTTP(rr, req)
281
+	if rr.Code != http.StatusOK {
282
+		t.Fatalf("list status: %d", rr.Code)
283
+	}
284
+	var listed []apiComment
285
+	if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
286
+		t.Fatalf("decode list: %v", err)
287
+	}
288
+	if len(listed) != 1 || listed[0].ID != created.ID {
289
+		t.Errorf("list: %+v", listed)
290
+	}
291
+
292
+	patch, _ := json.Marshal(map[string]any{"body": "lgtm — second look"})
293
+	req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v1/repos/alice/demo/issues/comments/%d", created.ID), bytes.NewReader(patch))
294
+	req.Header.Set("Authorization", "Bearer "+token)
295
+	rr = httptest.NewRecorder()
296
+	router.ServeHTTP(rr, req)
297
+	if rr.Code != http.StatusOK {
298
+		t.Fatalf("update status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
299
+	}
300
+
301
+	req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/repos/alice/demo/issues/comments/%d", created.ID), nil)
302
+	req.Header.Set("Authorization", "Bearer "+token)
303
+	rr = httptest.NewRecorder()
304
+	router.ServeHTTP(rr, req)
305
+	if rr.Code != http.StatusNoContent {
306
+		t.Fatalf("delete status: got %d, want 204; body=%s", rr.Code, rr.Body.String())
307
+	}
308
+}
309
+
310
+func TestIssues_CommentEditByNonAuthorForbidden(t *testing.T) {
311
+	pool, router, _, _, tokenAlice := seedIssuesEnv(t, "alice")
312
+
313
+	body, _ := json.Marshal(map[string]any{"title": "thread"})
314
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues", bytes.NewReader(body))
315
+	req.Header.Set("Authorization", "Bearer "+tokenAlice)
316
+	rr := httptest.NewRecorder()
317
+	router.ServeHTTP(rr, req)
318
+	if rr.Code != http.StatusCreated {
319
+		t.Fatalf("issue seed: %d", rr.Code)
320
+	}
321
+
322
+	body, _ = json.Marshal(map[string]any{"body": "alice's comment"})
323
+	req = httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues/1/comments", bytes.NewReader(body))
324
+	req.Header.Set("Authorization", "Bearer "+tokenAlice)
325
+	rr = httptest.NewRecorder()
326
+	router.ServeHTTP(rr, req)
327
+	if rr.Code != http.StatusCreated {
328
+		t.Fatalf("comment seed: %d", rr.Code)
329
+	}
330
+	var created apiComment
331
+	_ = json.Unmarshal(rr.Body.Bytes(), &created)
332
+
333
+	bobID := seedRepoCreatorUser(t, pool, "bob")
334
+	tokenBob := mintRunnerAPIPAT(t, pool, bobID, string(pat.ScopeRepoWrite))
335
+
336
+	patch, _ := json.Marshal(map[string]any{"body": "hijacked"})
337
+	req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v1/repos/alice/demo/issues/comments/%d", created.ID), bytes.NewReader(patch))
338
+	req.Header.Set("Authorization", "Bearer "+tokenBob)
339
+	rr = httptest.NewRecorder()
340
+	router.ServeHTTP(rr, req)
341
+	if rr.Code != http.StatusForbidden {
342
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
343
+	}
344
+}
345
+
346
+func TestIssues_LockUnlock(t *testing.T) {
347
+	_, router, _, _, token := seedIssuesEnv(t, "alice")
348
+
349
+	body, _ := json.Marshal(map[string]any{"title": "spicy"})
350
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/issues", bytes.NewReader(body))
351
+	req.Header.Set("Authorization", "Bearer "+token)
352
+	rr := httptest.NewRecorder()
353
+	router.ServeHTTP(rr, req)
354
+	if rr.Code != http.StatusCreated {
355
+		t.Fatalf("seed: %d", rr.Code)
356
+	}
357
+
358
+	req = httptest.NewRequest(http.MethodPut, "/api/v1/repos/alice/demo/issues/1/lock", strings.NewReader(`{"lock_reason":"off-topic"}`))
359
+	req.Header.Set("Authorization", "Bearer "+token)
360
+	rr = httptest.NewRecorder()
361
+	router.ServeHTTP(rr, req)
362
+	if rr.Code != http.StatusNoContent {
363
+		t.Fatalf("lock status: got %d, want 204; body=%s", rr.Code, rr.Body.String())
364
+	}
365
+
366
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/issues/1", nil)
367
+	req.Header.Set("Authorization", "Bearer "+token)
368
+	rr = httptest.NewRecorder()
369
+	router.ServeHTTP(rr, req)
370
+	if rr.Code != http.StatusOK {
371
+		t.Fatalf("get: %d", rr.Code)
372
+	}
373
+	var fetched apiIssue
374
+	_ = json.Unmarshal(rr.Body.Bytes(), &fetched)
375
+	if !fetched.Locked || fetched.LockReason != "off-topic" {
376
+		t.Errorf("locked state: %+v", fetched)
377
+	}
378
+
379
+	req = httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/issues/1/lock", nil)
380
+	req.Header.Set("Authorization", "Bearer "+token)
381
+	rr = httptest.NewRecorder()
382
+	router.ServeHTTP(rr, req)
383
+	if rr.Code != http.StatusNoContent {
384
+		t.Fatalf("unlock status: got %d, want 204; body=%s", rr.Code, rr.Body.String())
385
+	}
386
+}
internal/web/handlers/api/labels_test.goadded
@@ -0,0 +1,163 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api_test
4
+
5
+import (
6
+	"bytes"
7
+	"encoding/json"
8
+	"net/http"
9
+	"net/http/httptest"
10
+	"testing"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
13
+)
14
+
15
+type apiLabel struct {
16
+	ID          int64  `json:"id"`
17
+	Name        string `json:"name"`
18
+	Color       string `json:"color"`
19
+	Description string `json:"description"`
20
+	CreatedAt   string `json:"created_at"`
21
+}
22
+
23
+func TestLabels_CreateListGetUpdateDelete(t *testing.T) {
24
+	_, router, _, _, token := seedIssuesEnv(t, "alice")
25
+
26
+	// Default-seeded labels exist; list should already be non-empty.
27
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/labels", nil)
28
+	req.Header.Set("Authorization", "Bearer "+token)
29
+	rr := httptest.NewRecorder()
30
+	router.ServeHTTP(rr, req)
31
+	if rr.Code != http.StatusOK {
32
+		t.Fatalf("initial list: %d", rr.Code)
33
+	}
34
+	var initial []apiLabel
35
+	if err := json.Unmarshal(rr.Body.Bytes(), &initial); err != nil {
36
+		t.Fatalf("decode initial: %v", err)
37
+	}
38
+	if len(initial) == 0 {
39
+		t.Fatalf("expected seeded default labels; got empty list")
40
+	}
41
+
42
+	// Create a new label.
43
+	body, _ := json.Marshal(map[string]any{"name": "needs-triage", "color": "ff00aa", "description": "triage me"})
44
+	req = httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/labels", bytes.NewReader(body))
45
+	req.Header.Set("Authorization", "Bearer "+token)
46
+	rr = httptest.NewRecorder()
47
+	router.ServeHTTP(rr, req)
48
+	if rr.Code != http.StatusCreated {
49
+		t.Fatalf("create: %d; body=%s", rr.Code, rr.Body.String())
50
+	}
51
+	var created apiLabel
52
+	if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
53
+		t.Fatalf("decode create: %v", err)
54
+	}
55
+	if created.Name != "needs-triage" || created.Color != "ff00aa" {
56
+		t.Errorf("shape: %+v", created)
57
+	}
58
+
59
+	// Get by name.
60
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/labels/needs-triage", nil)
61
+	req.Header.Set("Authorization", "Bearer "+token)
62
+	rr = httptest.NewRecorder()
63
+	router.ServeHTTP(rr, req)
64
+	if rr.Code != http.StatusOK {
65
+		t.Fatalf("get: %d; body=%s", rr.Code, rr.Body.String())
66
+	}
67
+
68
+	// Update color + description.
69
+	patch, _ := json.Marshal(map[string]any{"color": "00ff00", "description": "ready for triage"})
70
+	req = httptest.NewRequest(http.MethodPatch, "/api/v1/repos/alice/demo/labels/needs-triage", bytes.NewReader(patch))
71
+	req.Header.Set("Authorization", "Bearer "+token)
72
+	rr = httptest.NewRecorder()
73
+	router.ServeHTTP(rr, req)
74
+	if rr.Code != http.StatusOK {
75
+		t.Fatalf("update: %d; body=%s", rr.Code, rr.Body.String())
76
+	}
77
+	var updated apiLabel
78
+	if err := json.Unmarshal(rr.Body.Bytes(), &updated); err != nil {
79
+		t.Fatalf("decode update: %v", err)
80
+	}
81
+	if updated.Color != "00ff00" || updated.Description != "ready for triage" {
82
+		t.Errorf("updated shape: %+v", updated)
83
+	}
84
+
85
+	// Delete.
86
+	req = httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/labels/needs-triage", nil)
87
+	req.Header.Set("Authorization", "Bearer "+token)
88
+	rr = httptest.NewRecorder()
89
+	router.ServeHTTP(rr, req)
90
+	if rr.Code != http.StatusNoContent {
91
+		t.Fatalf("delete: %d; body=%s", rr.Code, rr.Body.String())
92
+	}
93
+
94
+	// Re-get returns 404.
95
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/labels/needs-triage", nil)
96
+	req.Header.Set("Authorization", "Bearer "+token)
97
+	rr = httptest.NewRecorder()
98
+	router.ServeHTTP(rr, req)
99
+	if rr.Code != http.StatusNotFound {
100
+		t.Fatalf("post-delete get: %d", rr.Code)
101
+	}
102
+}
103
+
104
+func TestLabels_CreateRejectsBadColor(t *testing.T) {
105
+	_, router, _, _, token := seedIssuesEnv(t, "alice")
106
+	body, _ := json.Marshal(map[string]any{"name": "weird", "color": "not-hex"})
107
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/labels", bytes.NewReader(body))
108
+	req.Header.Set("Authorization", "Bearer "+token)
109
+	rr := httptest.NewRecorder()
110
+	router.ServeHTTP(rr, req)
111
+	if rr.Code != http.StatusUnprocessableEntity {
112
+		t.Fatalf("status: got %d, want 422; body=%s", rr.Code, rr.Body.String())
113
+	}
114
+}
115
+
116
+func TestLabels_CreateRejectsDuplicate(t *testing.T) {
117
+	_, router, _, _, token := seedIssuesEnv(t, "alice")
118
+	body, _ := json.Marshal(map[string]any{"name": "dup", "color": "112233"})
119
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/labels", bytes.NewReader(body))
120
+	req.Header.Set("Authorization", "Bearer "+token)
121
+	rr := httptest.NewRecorder()
122
+	router.ServeHTTP(rr, req)
123
+	if rr.Code != http.StatusCreated {
124
+		t.Fatalf("first create: %d", rr.Code)
125
+	}
126
+
127
+	req = httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/labels", bytes.NewReader(body))
128
+	req.Header.Set("Authorization", "Bearer "+token)
129
+	rr = httptest.NewRecorder()
130
+	router.ServeHTTP(rr, req)
131
+	if rr.Code != http.StatusConflict {
132
+		t.Fatalf("dup create: got %d, want 409; body=%s", rr.Code, rr.Body.String())
133
+	}
134
+}
135
+
136
+func TestLabels_RequiresWriteScopeForCreate(t *testing.T) {
137
+	pool, router, userID, _, _ := seedIssuesEnv(t, "alice")
138
+	readOnly := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeRepoRead))
139
+
140
+	body, _ := json.Marshal(map[string]any{"name": "noscope", "color": "aabbcc"})
141
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/labels", bytes.NewReader(body))
142
+	req.Header.Set("Authorization", "Bearer "+readOnly)
143
+	rr := httptest.NewRecorder()
144
+	router.ServeHTTP(rr, req)
145
+	if rr.Code != http.StatusForbidden {
146
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
147
+	}
148
+}
149
+
150
+func TestLabels_NonOwnerForbidden(t *testing.T) {
151
+	pool, router, _, _, _ := seedIssuesEnv(t, "alice")
152
+	bobID := seedRepoCreatorUser(t, pool, "bob")
153
+	tokenBob := mintRunnerAPIPAT(t, pool, bobID, string(pat.ScopeRepoWrite))
154
+
155
+	body, _ := json.Marshal(map[string]any{"name": "bobs-label", "color": "445566"})
156
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/labels", bytes.NewReader(body))
157
+	req.Header.Set("Authorization", "Bearer "+tokenBob)
158
+	rr := httptest.NewRecorder()
159
+	router.ServeHTTP(rr, req)
160
+	if rr.Code != http.StatusNotFound {
161
+		t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String())
162
+	}
163
+}