tenseleyflow/shithub / 7f5534e

Browse files

api: cross-cutting contract tests (envelope, headers, meta, rate limit)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7f5534ea9d2808943625f7066f8c484b48a6bd94
Parents
828eb18
Tree
4fd7386

1 changed file

StatusFile+-
A internal/web/handlers/api/cross_test.go 280 0
internal/web/handlers/api/cross_test.goadded
@@ -0,0 +1,280 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api_test
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"io"
9
+	"log/slog"
10
+	"net/http"
11
+	"net/http/httptest"
12
+	"strings"
13
+	"testing"
14
+
15
+	"github.com/go-chi/chi/v5"
16
+	"github.com/jackc/pgx/v5/pgxpool"
17
+
18
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
19
+	"github.com/tenseleyFlow/shithub/internal/ratelimit"
20
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
21
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
22
+	apih "github.com/tenseleyFlow/shithub/internal/web/handlers/api"
23
+	"github.com/tenseleyFlow/shithub/internal/version"
24
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apilimit"
25
+)
26
+
27
+// newCrossCuttingAPIRouter builds the smallest /api/v1 router we can —
28
+// no runner JWT, no secret box, no object store. Enough to exercise the
29
+// PATAuth + RequireScope + apilimit + meta surface.
30
+func newCrossCuttingAPIRouter(t *testing.T, pool *pgxpool.Pool) http.Handler {
31
+	t.Helper()
32
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
33
+	h, err := apih.New(apih.Deps{
34
+		Pool:        pool,
35
+		Logger:      logger,
36
+		RateLimiter: ratelimit.New(pool),
37
+		BaseURL:     "https://shithub.test",
38
+		APILimit: apilimit.Config{
39
+			AuthedPerHour: 5000,
40
+			AnonPerHour:   60,
41
+			Logger:        logger,
42
+		},
43
+	})
44
+	if err != nil {
45
+		t.Fatalf("api.New: %v", err)
46
+	}
47
+	r := chi.NewRouter()
48
+	h.Mount(r)
49
+	return r
50
+}
51
+
52
+func crossCuttingUser(t *testing.T, pool *pgxpool.Pool) int64 {
53
+	t.Helper()
54
+	user, err := usersdb.New().CreateUser(context.Background(), pool, usersdb.CreateUserParams{
55
+		Username:     "alice",
56
+		DisplayName:  "Alice",
57
+		PasswordHash: runnerAPIFixtureHash,
58
+	})
59
+	if err != nil {
60
+		t.Fatalf("CreateUser: %v", err)
61
+	}
62
+	return user.ID
63
+}
64
+
65
+func TestCrossCutting_AuthFailureReturnsJSONEnvelope(t *testing.T) {
66
+	pool := dbtest.NewTestDB(t)
67
+	router := newCrossCuttingAPIRouter(t, pool)
68
+
69
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/user", nil)
70
+	req.Header.Set("Authorization", "Bearer not-a-real-token")
71
+	rr := httptest.NewRecorder()
72
+	router.ServeHTTP(rr, req)
73
+
74
+	if rr.Code != http.StatusUnauthorized {
75
+		t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
76
+	}
77
+	if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
78
+		t.Errorf("Content-Type: got %q, want application/json prefix", ct)
79
+	}
80
+	if wa := rr.Header().Get("WWW-Authenticate"); !strings.Contains(wa, "Bearer") {
81
+		t.Errorf("WWW-Authenticate missing Bearer challenge: %q", wa)
82
+	}
83
+	var envelope struct {
84
+		Error string `json:"error"`
85
+	}
86
+	if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
87
+		t.Fatalf("decode error envelope: %v; body=%s", err, rr.Body.String())
88
+	}
89
+	if envelope.Error == "" {
90
+		t.Errorf("error envelope empty: %s", rr.Body.String())
91
+	}
92
+}
93
+
94
+func TestCrossCutting_ScopeRejectReturnsJSONEnvelope(t *testing.T) {
95
+	pool := dbtest.NewTestDB(t)
96
+	router := newCrossCuttingAPIRouter(t, pool)
97
+	userID := crossCuttingUser(t, pool)
98
+	// User has only user:read; /api/v1/user needs user:read, so to
99
+	// exercise a scope reject we use a different route that requires
100
+	// repo:write. We don't have a repo wired here, so we forge a path
101
+	// the scope-decorator wrapped around check-runs, knowing it will
102
+	// short-circuit on scope before policy resolution. The scope check
103
+	// runs before the resolveAPIRepo call, so we get 403 directly.
104
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
105
+
106
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/check-runs", strings.NewReader(`{}`))
107
+	req.Header.Set("Authorization", "Bearer "+token)
108
+	rr := httptest.NewRecorder()
109
+	router.ServeHTTP(rr, req)
110
+
111
+	if rr.Code != http.StatusForbidden {
112
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
113
+	}
114
+	if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
115
+		t.Errorf("Content-Type: got %q, want application/json prefix", ct)
116
+	}
117
+	if want := string(pat.ScopeRepoWrite); rr.Header().Get("X-Accepted-OAuth-Scopes") != want {
118
+		t.Errorf("X-Accepted-OAuth-Scopes: got %q, want %q", rr.Header().Get("X-Accepted-OAuth-Scopes"), want)
119
+	}
120
+	if got := rr.Header().Get("X-OAuth-Scopes"); got != string(pat.ScopeUserRead) {
121
+		t.Errorf("X-OAuth-Scopes: got %q, want %q", got, pat.ScopeUserRead)
122
+	}
123
+	var envelope struct {
124
+		Error string `json:"error"`
125
+	}
126
+	if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
127
+		t.Fatalf("decode error envelope: %v; body=%s", err, rr.Body.String())
128
+	}
129
+	if !strings.Contains(envelope.Error, "scope") {
130
+		t.Errorf("error envelope: got %q, want one mentioning scope", envelope.Error)
131
+	}
132
+}
133
+
134
+func TestCrossCutting_XOAuthScopesOnSuccess(t *testing.T) {
135
+	pool := dbtest.NewTestDB(t)
136
+	router := newCrossCuttingAPIRouter(t, pool)
137
+	userID := crossCuttingUser(t, pool)
138
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
139
+
140
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/user", nil)
141
+	req.Header.Set("Authorization", "Bearer "+token)
142
+	rr := httptest.NewRecorder()
143
+	router.ServeHTTP(rr, req)
144
+
145
+	if rr.Code != http.StatusOK {
146
+		t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
147
+	}
148
+	if got := rr.Header().Get("X-OAuth-Scopes"); got != string(pat.ScopeUserRead) {
149
+		t.Errorf("X-OAuth-Scopes: got %q, want %q", got, pat.ScopeUserRead)
150
+	}
151
+}
152
+
153
+func TestCrossCutting_RateLimitHeadersStampedAuthed(t *testing.T) {
154
+	pool := dbtest.NewTestDB(t)
155
+	router := newCrossCuttingAPIRouter(t, pool)
156
+	userID := crossCuttingUser(t, pool)
157
+	token := mintRunnerAPIPAT(t, pool, userID, string(pat.ScopeUserRead))
158
+
159
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/user", nil)
160
+	req.Header.Set("Authorization", "Bearer "+token)
161
+	req.RemoteAddr = "10.0.0.5:12345"
162
+	rr := httptest.NewRecorder()
163
+	router.ServeHTTP(rr, req)
164
+
165
+	if rr.Code != http.StatusOK {
166
+		t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
167
+	}
168
+	if got := rr.Header().Get("X-RateLimit-Limit"); got != "5000" {
169
+		t.Errorf("X-RateLimit-Limit: got %q, want 5000", got)
170
+	}
171
+	if rr.Header().Get("X-RateLimit-Remaining") == "" {
172
+		t.Errorf("X-RateLimit-Remaining missing")
173
+	}
174
+	if rr.Header().Get("X-RateLimit-Reset") == "" {
175
+		t.Errorf("X-RateLimit-Reset missing")
176
+	}
177
+}
178
+
179
+func TestCrossCutting_RateLimitHeadersStampedAnon(t *testing.T) {
180
+	pool := dbtest.NewTestDB(t)
181
+	router := newCrossCuttingAPIRouter(t, pool)
182
+
183
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
184
+	req.RemoteAddr = "10.0.0.6:54321"
185
+	rr := httptest.NewRecorder()
186
+	router.ServeHTTP(rr, req)
187
+
188
+	if rr.Code != http.StatusOK {
189
+		t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
190
+	}
191
+	if got := rr.Header().Get("X-RateLimit-Limit"); got != "60" {
192
+		t.Errorf("X-RateLimit-Limit (anon): got %q, want 60", got)
193
+	}
194
+}
195
+
196
+func TestCrossCutting_MetaPayload(t *testing.T) {
197
+	pool := dbtest.NewTestDB(t)
198
+	router := newCrossCuttingAPIRouter(t, pool)
199
+
200
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
201
+	rr := httptest.NewRecorder()
202
+	router.ServeHTTP(rr, req)
203
+
204
+	if rr.Code != http.StatusOK {
205
+		t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
206
+	}
207
+	var resp struct {
208
+		Version      string   `json:"version"`
209
+		Commit       string   `json:"commit"`
210
+		BuiltAt      string   `json:"built_at"`
211
+		Capabilities []string `json:"capabilities"`
212
+	}
213
+	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
214
+		t.Fatalf("decode meta: %v; body=%s", err, rr.Body.String())
215
+	}
216
+	if resp.Version != version.Version {
217
+		t.Errorf("version: got %q, want %q", resp.Version, version.Version)
218
+	}
219
+	if len(resp.Capabilities) == 0 {
220
+		t.Errorf("capabilities empty: %#v", resp)
221
+	}
222
+	if !containsString(resp.Capabilities, "pat-auth") {
223
+		t.Errorf("capabilities missing pat-auth: %v", resp.Capabilities)
224
+	}
225
+}
226
+
227
+func TestCrossCutting_RateLimitDeniedJSON(t *testing.T) {
228
+	pool := dbtest.NewTestDB(t)
229
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
230
+	// Tiny limits so we can trigger the deny path without 5001 requests.
231
+	h, err := apih.New(apih.Deps{
232
+		Pool:        pool,
233
+		Logger:      logger,
234
+		RateLimiter: ratelimit.New(pool),
235
+		BaseURL:     "https://shithub.test",
236
+		APILimit: apilimit.Config{
237
+			AuthedPerHour: 1,
238
+			AnonPerHour:   1,
239
+			Logger:        logger,
240
+		},
241
+	})
242
+	if err != nil {
243
+		t.Fatalf("api.New: %v", err)
244
+	}
245
+	router := chi.NewRouter()
246
+	h.Mount(router)
247
+
248
+	// First anon request consumes the entire budget.
249
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
250
+	req.RemoteAddr = "10.0.0.99:11111"
251
+	rr := httptest.NewRecorder()
252
+	router.ServeHTTP(rr, req)
253
+	if rr.Code != http.StatusOK {
254
+		t.Fatalf("first call status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
255
+	}
256
+
257
+	// Second request exceeds the budget.
258
+	req = httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
259
+	req.RemoteAddr = "10.0.0.99:11112"
260
+	rr = httptest.NewRecorder()
261
+	router.ServeHTTP(rr, req)
262
+	if rr.Code != http.StatusTooManyRequests {
263
+		t.Fatalf("second call status: got %d, want 429; body=%s", rr.Code, rr.Body.String())
264
+	}
265
+	if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
266
+		t.Errorf("Content-Type: got %q, want application/json prefix", ct)
267
+	}
268
+	if rr.Header().Get("Retry-After") == "" {
269
+		t.Errorf("Retry-After missing on 429")
270
+	}
271
+	var envelope struct {
272
+		Error string `json:"error"`
273
+	}
274
+	if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
275
+		t.Fatalf("decode 429 envelope: %v; body=%s", err, rr.Body.String())
276
+	}
277
+	if !strings.Contains(envelope.Error, "rate") {
278
+		t.Errorf("429 error: got %q", envelope.Error)
279
+	}
280
+}