Go · 9453 bytes Raw Blame History
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 "github.com/tenseleyFlow/shithub/internal/version"
23 apih "github.com/tenseleyFlow/shithub/internal/web/handlers/api"
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 }
281