tenseleyflow/shithub / c70cabf

Browse files

api/actions_caches: integration tests

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
c70cabfe9d08d72b165907e6e4a691e2269f1fea
Parents
46b25c6
Tree
b27b02b

1 changed file

StatusFile+-
A internal/web/handlers/api/actions_caches_test.go 235 0
internal/web/handlers/api/actions_caches_test.goadded
@@ -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
+}