@@ -0,0 +1,235 @@ |
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + |
| 3 | +package api_test |
| 4 | + |
| 5 | +import ( |
| 6 | + "context" |
| 7 | + "encoding/json" |
| 8 | + "net/http" |
| 9 | + "net/http/httptest" |
| 10 | + "strconv" |
| 11 | + "strings" |
| 12 | + "testing" |
| 13 | + |
| 14 | + "github.com/jackc/pgx/v5/pgxpool" |
| 15 | + |
| 16 | + actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
| 17 | + "github.com/tenseleyFlow/shithub/internal/auth/pat" |
| 18 | +) |
| 19 | + |
| 20 | +// seedCache inserts a workflow_caches row directly. Bypasses any |
| 21 | +// future runner-side upload protocol so REST tests can exercise the |
| 22 | +// list/delete surface without spinning up a runner fixture. |
| 23 | +func seedCache(t *testing.T, pool *pgxpool.Pool, repoID int64, key, version, ref string) actionsdb.WorkflowCache { |
| 24 | + t.Helper() |
| 25 | + row, err := actionsdb.New().InsertWorkflowCache(context.Background(), pool, actionsdb.InsertWorkflowCacheParams{ |
| 26 | + RepoID: repoID, |
| 27 | + CacheKey: key, |
| 28 | + CacheVersion: version, |
| 29 | + GitRef: ref, |
| 30 | + ObjectKey: "actions/caches/r" + strconv.FormatInt(repoID, 10) + "/" + key + "-" + version, |
| 31 | + SizeBytes: 1024, |
| 32 | + }) |
| 33 | + if err != nil { |
| 34 | + t.Fatalf("InsertWorkflowCache: %v", err) |
| 35 | + } |
| 36 | + return row |
| 37 | +} |
| 38 | + |
| 39 | +type cachesListResponse struct { |
| 40 | + TotalCount int64 `json:"total_count"` |
| 41 | + ActionsCaches []cacheRowEntry `json:"actions_caches"` |
| 42 | +} |
| 43 | + |
| 44 | +type cacheRowEntry struct { |
| 45 | + ID int64 `json:"id"` |
| 46 | + Key string `json:"key"` |
| 47 | + Version string `json:"version"` |
| 48 | + Ref string `json:"ref"` |
| 49 | + SizeBytes int64 `json:"size_bytes"` |
| 50 | +} |
| 51 | + |
| 52 | +func TestActionsCaches_ListReturnsSeededRows(t *testing.T) { |
| 53 | + pool, router, _, token, _, _ := seedBranchesEnv(t, "alice") |
| 54 | + repoID := getRepoIDForDemo(t, pool) |
| 55 | + seedCache(t, pool, repoID, "node-modules", "v1", "refs/heads/trunk") |
| 56 | + seedCache(t, pool, repoID, "go-mod", "v2", "refs/heads/feature/x") |
| 57 | + |
| 58 | + req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/caches", nil) |
| 59 | + req.Header.Set("Authorization", "Bearer "+token) |
| 60 | + rr := httptest.NewRecorder() |
| 61 | + router.ServeHTTP(rr, req) |
| 62 | + if rr.Code != http.StatusOK { |
| 63 | + t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String()) |
| 64 | + } |
| 65 | + var got cachesListResponse |
| 66 | + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { |
| 67 | + t.Fatalf("decode: %v", err) |
| 68 | + } |
| 69 | + if got.TotalCount != 2 { |
| 70 | + t.Errorf("total_count: got %d, want 2", got.TotalCount) |
| 71 | + } |
| 72 | + if len(got.ActionsCaches) != 2 { |
| 73 | + t.Fatalf("expected 2 caches; got %+v", got) |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +func TestActionsCaches_FilterByKey(t *testing.T) { |
| 78 | + pool, router, _, token, _, _ := seedBranchesEnv(t, "alice") |
| 79 | + repoID := getRepoIDForDemo(t, pool) |
| 80 | + seedCache(t, pool, repoID, "node-modules", "v1", "refs/heads/trunk") |
| 81 | + seedCache(t, pool, repoID, "go-mod", "v2", "refs/heads/trunk") |
| 82 | + |
| 83 | + req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/caches?key=node-modules", nil) |
| 84 | + req.Header.Set("Authorization", "Bearer "+token) |
| 85 | + rr := httptest.NewRecorder() |
| 86 | + router.ServeHTTP(rr, req) |
| 87 | + var got cachesListResponse |
| 88 | + _ = json.Unmarshal(rr.Body.Bytes(), &got) |
| 89 | + if got.TotalCount != 1 || len(got.ActionsCaches) != 1 { |
| 90 | + t.Errorf("filter by key: %+v", got) |
| 91 | + } |
| 92 | + if got.ActionsCaches[0].Key != "node-modules" { |
| 93 | + t.Errorf("returned wrong row: %+v", got.ActionsCaches[0]) |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +func TestActionsCaches_FilterByRef(t *testing.T) { |
| 98 | + pool, router, _, token, _, _ := seedBranchesEnv(t, "alice") |
| 99 | + repoID := getRepoIDForDemo(t, pool) |
| 100 | + seedCache(t, pool, repoID, "node-modules", "v1", "refs/heads/trunk") |
| 101 | + seedCache(t, pool, repoID, "node-modules", "v1", "refs/heads/feature/x") |
| 102 | + |
| 103 | + req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/caches?ref=refs/heads/trunk", nil) |
| 104 | + req.Header.Set("Authorization", "Bearer "+token) |
| 105 | + rr := httptest.NewRecorder() |
| 106 | + router.ServeHTTP(rr, req) |
| 107 | + var got cachesListResponse |
| 108 | + _ = json.Unmarshal(rr.Body.Bytes(), &got) |
| 109 | + if got.TotalCount != 1 || len(got.ActionsCaches) != 1 { |
| 110 | + t.Errorf("filter by ref: %+v", got) |
| 111 | + } |
| 112 | + if got.ActionsCaches[0].Ref != "refs/heads/trunk" { |
| 113 | + t.Errorf("returned wrong row: %+v", got.ActionsCaches[0]) |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +func TestActionsCaches_DeleteByID(t *testing.T) { |
| 118 | + pool, router, _, _, _, _ := seedBranchesEnv(t, "alice") |
| 119 | + repoID := getRepoIDForDemo(t, pool) |
| 120 | + cache := seedCache(t, pool, repoID, "node-modules", "v1", "refs/heads/trunk") |
| 121 | + writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite)) |
| 122 | + |
| 123 | + req := httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/actions/caches/"+strconv.FormatInt(cache.ID, 10), nil) |
| 124 | + req.Header.Set("Authorization", "Bearer "+writeToken) |
| 125 | + rr := httptest.NewRecorder() |
| 126 | + router.ServeHTTP(rr, req) |
| 127 | + if rr.Code != http.StatusNoContent { |
| 128 | + t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String()) |
| 129 | + } |
| 130 | + if _, err := actionsdb.New().GetWorkflowCacheByID(context.Background(), pool, cache.ID); err == nil { |
| 131 | + t.Errorf("row still present after delete") |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +func TestActionsCaches_DeleteByIDUnknown404(t *testing.T) { |
| 136 | + pool, router, _, _, _, _ := seedBranchesEnv(t, "alice") |
| 137 | + writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite)) |
| 138 | + |
| 139 | + req := httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/actions/caches/99999", nil) |
| 140 | + req.Header.Set("Authorization", "Bearer "+writeToken) |
| 141 | + rr := httptest.NewRecorder() |
| 142 | + router.ServeHTTP(rr, req) |
| 143 | + if rr.Code != http.StatusNotFound { |
| 144 | + t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String()) |
| 145 | + } |
| 146 | +} |
| 147 | + |
| 148 | +func TestActionsCaches_DeleteByKey(t *testing.T) { |
| 149 | + pool, router, _, _, _, _ := seedBranchesEnv(t, "alice") |
| 150 | + repoID := getRepoIDForDemo(t, pool) |
| 151 | + seedCache(t, pool, repoID, "node-modules", "v1", "refs/heads/trunk") |
| 152 | + seedCache(t, pool, repoID, "node-modules", "v2", "refs/heads/trunk") |
| 153 | + seedCache(t, pool, repoID, "go-mod", "v1", "refs/heads/trunk") |
| 154 | + writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite)) |
| 155 | + |
| 156 | + req := httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/actions/caches?key=node-modules", nil) |
| 157 | + req.Header.Set("Authorization", "Bearer "+writeToken) |
| 158 | + rr := httptest.NewRecorder() |
| 159 | + router.ServeHTTP(rr, req) |
| 160 | + if rr.Code != http.StatusNoContent { |
| 161 | + t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String()) |
| 162 | + } |
| 163 | + // Only go-mod should remain. |
| 164 | + count, _ := actionsdb.New().CountWorkflowCachesForRepo(context.Background(), pool, actionsdb.CountWorkflowCachesForRepoParams{RepoID: repoID}) |
| 165 | + if count != 1 { |
| 166 | + t.Errorf("remaining caches: got %d, want 1 (go-mod)", count) |
| 167 | + } |
| 168 | +} |
| 169 | + |
| 170 | +func TestActionsCaches_DeleteByKeyRequiresKeyParam(t *testing.T) { |
| 171 | + pool, router, _, _, _, _ := seedBranchesEnv(t, "alice") |
| 172 | + writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite)) |
| 173 | + |
| 174 | + req := httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/actions/caches", nil) |
| 175 | + req.Header.Set("Authorization", "Bearer "+writeToken) |
| 176 | + rr := httptest.NewRecorder() |
| 177 | + router.ServeHTTP(rr, req) |
| 178 | + if rr.Code != http.StatusBadRequest { |
| 179 | + t.Fatalf("status: got %d, want 400; body=%s", rr.Code, rr.Body.String()) |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | +func TestActionsCaches_DeleteRequiresRepoWrite(t *testing.T) { |
| 184 | + _, router, _, token, _, _ := seedBranchesEnv(t, "alice") |
| 185 | + |
| 186 | + req := httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/actions/caches/1", nil) |
| 187 | + req.Header.Set("Authorization", "Bearer "+token) // repo:read only |
| 188 | + rr := httptest.NewRecorder() |
| 189 | + router.ServeHTTP(rr, req) |
| 190 | + if rr.Code != http.StatusForbidden { |
| 191 | + t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String()) |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | +func TestActionsCaches_CrossRepo404OnDeleteByID(t *testing.T) { |
| 196 | + pool, router, _, _, _, _ := seedBranchesEnv(t, "alice") |
| 197 | + repoID := getRepoIDForDemo(t, pool) |
| 198 | + cache := seedCache(t, pool, repoID, "node-modules", "v1", "refs/heads/trunk") |
| 199 | + |
| 200 | + // bob's repo + token, trying to delete alice's cache. |
| 201 | + bobID := seedRepoCreatorUser(t, pool, "bob") |
| 202 | + // We don't need to actually create bob's repo for this guard test; |
| 203 | + // the URL path `/repos/alice/demo/.../caches/<id>` still resolves |
| 204 | + // to alice's repo and the cross-repo check is on the run's repo_id. |
| 205 | + // We just need a bob token without alice's repo permissions. |
| 206 | + bobToken := mintRunnerAPIPAT(t, pool, bobID, string(pat.ScopeRepoWrite)) |
| 207 | + _ = bobID |
| 208 | + |
| 209 | + // Sanity: deletion targets alice's URL but bob's PAT — policy should |
| 210 | + // resolve repo and gate via ActionRepoWrite; bob has no write on |
| 211 | + // alice/demo, so the deny lands at 404 (existence-leak-safe). |
| 212 | + req := httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/actions/caches/"+strconv.FormatInt(cache.ID, 10), nil) |
| 213 | + req.Header.Set("Authorization", "Bearer "+bobToken) |
| 214 | + rr := httptest.NewRecorder() |
| 215 | + router.ServeHTTP(rr, req) |
| 216 | + if rr.Code != http.StatusNotFound { |
| 217 | + t.Fatalf("status: got %d, want 404; body=%s; url=%s", rr.Code, rr.Body.String(), req.URL.String()) |
| 218 | + } |
| 219 | + if !strings.Contains(rr.Body.String(), "not found") { |
| 220 | + t.Errorf("body should be a not-found envelope: %s", rr.Body.String()) |
| 221 | + } |
| 222 | +} |
| 223 | + |
| 224 | +// getRepoIDForDemo resolves alice/demo's repo_id. seedBranchesEnv |
| 225 | +// doesn't return it directly; this helper covers the gap. |
| 226 | +func getRepoIDForDemo(t *testing.T, pool *pgxpool.Pool) int64 { |
| 227 | + t.Helper() |
| 228 | + var id int64 |
| 229 | + if err := pool.QueryRow(context.Background(), |
| 230 | + "SELECT id FROM repos WHERE name = 'demo' AND owner_user_id = (SELECT id FROM users WHERE username = 'alice')", |
| 231 | + ).Scan(&id); err != nil { |
| 232 | + t.Fatalf("lookup demo repo: %v", err) |
| 233 | + } |
| 234 | + return id |
| 235 | +} |