tenseleyflow/shithub / 1d1ecfa

Browse files

api/actions_workflows: integration tests + missing-file 404 guard

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
1d1ecfa4a5846e600f5743abfd0d853a501e83e3
Parents
6c9b30c
Tree
6fad8ce

2 changed files

StatusFile+-
M internal/web/handlers/api/actions_workflows.go 5 1
A internal/web/handlers/api/actions_workflows_test.go 306 0
internal/web/handlers/api/actions_workflows.gomodified
@@ -181,7 +181,11 @@ func (h *Handlers) actionsWorkflowsDispatch(w http.ResponseWriter, r *http.Reque
181181
 		return
182182
 	}
183183
 	bytes, err := repogit.ReadBlobBytes(r.Context(), gitDir, headSHA, file, int64(workflow.MaxWorkflowFileBytes))
184
-	if err != nil {
184
+	if err != nil || len(bytes) == 0 {
185
+		// ReadBlobBytes silently returns (nil, nil) when the path is
186
+		// absent at the ref (git cat-file's stderr is discarded). An
187
+		// empty body is therefore indistinguishable from a missing
188
+		// file, which is the correct caller-facing answer either way.
185189
 		writeAPIError(w, http.StatusNotFound, fmt.Sprintf("workflow file %q not found at ref %s", file, branch))
186190
 		return
187191
 	}
internal/web/handlers/api/actions_workflows_test.goadded
@@ -0,0 +1,306 @@
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
+	"net/http"
10
+	"net/http/httptest"
11
+	"strings"
12
+	"testing"
13
+	"time"
14
+
15
+	"github.com/jackc/pgx/v5/pgxpool"
16
+
17
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
18
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
19
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
20
+)
21
+
22
+func ownerIDForAlice(t *testing.T, pool *pgxpool.Pool) int64 {
23
+	t.Helper()
24
+	u, err := usersdb.New().GetUserByUsername(context.Background(), pool, "alice")
25
+	if err != nil {
26
+		t.Fatalf("GetUserByUsername: %v", err)
27
+	}
28
+	return u.ID
29
+}
30
+
31
+type apiWorkflow struct {
32
+	ID    int64  `json:"id"`
33
+	Name  string `json:"name"`
34
+	Path  string `json:"path"`
35
+	File  string `json:"file"`
36
+	State string `json:"state"`
37
+}
38
+
39
+const ciYAML = `name: CI
40
+on:
41
+  push:
42
+  workflow_dispatch:
43
+    inputs:
44
+      env:
45
+        type: choice
46
+        options: [qa, prod]
47
+        default: qa
48
+      debug:
49
+        type: boolean
50
+jobs:
51
+  build:
52
+    runs-on: ubuntu-latest
53
+    steps:
54
+      - run: echo hi
55
+`
56
+
57
+const noDispatchYAML = `name: PushOnly
58
+on: [push]
59
+jobs:
60
+  build:
61
+    runs-on: ubuntu-latest
62
+    steps:
63
+      - run: echo hi
64
+`
65
+
66
+func seedRepoWithWorkflow(t *testing.T, gitDir string, files map[string]string) string {
67
+	t.Helper()
68
+	entries := []repogit.FileEntry{{Path: "README.md", Body: []byte("# demo\n")}}
69
+	for path, body := range files {
70
+		entries = append(entries, repogit.FileEntry{Path: path, Body: []byte(body)})
71
+	}
72
+	commit, err := (repogit.InitialCommit{
73
+		GitDir:      gitDir,
74
+		AuthorName:  "Alice",
75
+		AuthorEmail: "alice@example.test",
76
+		Branch:      "trunk",
77
+		Message:     "init",
78
+		When:        time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC),
79
+		Files:       entries,
80
+	}).Build(context.Background())
81
+	if err != nil {
82
+		t.Fatalf("InitialCommit.Build: %v", err)
83
+	}
84
+	return commit
85
+}
86
+
87
+func TestActionsWorkflows_ListReturnsDiscoveredFiles(t *testing.T) {
88
+	_, router, rfs, token, _, _ := seedBranchesEnv(t, "alice")
89
+	gitDir, err := rfs.RepoPath("alice", "demo")
90
+	if err != nil {
91
+		t.Fatalf("RepoPath: %v", err)
92
+	}
93
+	seedRepoWithWorkflow(t, gitDir, map[string]string{
94
+		".shithub/workflows/ci.yml":      ciYAML,
95
+		".shithub/workflows/release.yml": noDispatchYAML,
96
+	})
97
+
98
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/workflows", nil)
99
+	req.Header.Set("Authorization", "Bearer "+token)
100
+	rr := httptest.NewRecorder()
101
+	router.ServeHTTP(rr, req)
102
+	if rr.Code != http.StatusOK {
103
+		t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
104
+	}
105
+	var listed []apiWorkflow
106
+	if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
107
+		t.Fatalf("decode: %v", err)
108
+	}
109
+	if len(listed) != 2 {
110
+		t.Fatalf("expected 2 workflows; got %+v", listed)
111
+	}
112
+	byPath := map[string]apiWorkflow{}
113
+	for _, w := range listed {
114
+		byPath[w.Path] = w
115
+	}
116
+	if w := byPath[".shithub/workflows/ci.yml"]; w.Name != "CI" || w.File != "ci.yml" || w.State != "active" {
117
+		t.Errorf("ci.yml shape: %+v", w)
118
+	}
119
+	if w := byPath[".shithub/workflows/release.yml"]; w.Name != "PushOnly" || w.File != "release.yml" {
120
+		t.Errorf("release.yml shape: %+v", w)
121
+	}
122
+}
123
+
124
+func TestActionsWorkflows_ListEmptyRepoReturnsEmptyArray(t *testing.T) {
125
+	_, router, rfs, token, _, _ := seedBranchesEnv(t, "alice")
126
+	gitDir, err := rfs.RepoPath("alice", "demo")
127
+	if err != nil {
128
+		t.Fatalf("RepoPath: %v", err)
129
+	}
130
+	seedBranches(t, gitDir, nil, nil) // initial commit, no workflows
131
+
132
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/workflows", nil)
133
+	req.Header.Set("Authorization", "Bearer "+token)
134
+	rr := httptest.NewRecorder()
135
+	router.ServeHTTP(rr, req)
136
+	if rr.Code != http.StatusOK {
137
+		t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
138
+	}
139
+	if body := strings.TrimSpace(rr.Body.String()); body != "[]" {
140
+		t.Errorf("empty body: got %q, want []", body)
141
+	}
142
+}
143
+
144
+func TestActionsWorkflows_GetByFileName(t *testing.T) {
145
+	_, router, rfs, token, _, _ := seedBranchesEnv(t, "alice")
146
+	gitDir, err := rfs.RepoPath("alice", "demo")
147
+	if err != nil {
148
+		t.Fatalf("RepoPath: %v", err)
149
+	}
150
+	seedRepoWithWorkflow(t, gitDir, map[string]string{
151
+		".shithub/workflows/ci.yml": ciYAML,
152
+	})
153
+
154
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/workflows/ci.yml", nil)
155
+	req.Header.Set("Authorization", "Bearer "+token)
156
+	rr := httptest.NewRecorder()
157
+	router.ServeHTTP(rr, req)
158
+	if rr.Code != http.StatusOK {
159
+		t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
160
+	}
161
+	var got apiWorkflow
162
+	_ = json.Unmarshal(rr.Body.Bytes(), &got)
163
+	if got.Name != "CI" || got.File != "ci.yml" {
164
+		t.Errorf("shape: %+v", got)
165
+	}
166
+	if got.ID == 0 {
167
+		t.Errorf("missing id: %+v", got)
168
+	}
169
+}
170
+
171
+func TestActionsWorkflows_GetUnknown404(t *testing.T) {
172
+	_, router, rfs, token, _, _ := seedBranchesEnv(t, "alice")
173
+	gitDir, err := rfs.RepoPath("alice", "demo")
174
+	if err != nil {
175
+		t.Fatalf("RepoPath: %v", err)
176
+	}
177
+	seedRepoWithWorkflow(t, gitDir, map[string]string{
178
+		".shithub/workflows/ci.yml": ciYAML,
179
+	})
180
+
181
+	req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/actions/workflows/ghost.yml", nil)
182
+	req.Header.Set("Authorization", "Bearer "+token)
183
+	rr := httptest.NewRecorder()
184
+	router.ServeHTTP(rr, req)
185
+	if rr.Code != http.StatusNotFound {
186
+		t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String())
187
+	}
188
+}
189
+
190
+func TestActionsWorkflows_DispatchHappyPath(t *testing.T) {
191
+	pool, router, rfs, _, _, _ := seedBranchesEnv(t, "alice")
192
+	gitDir, err := rfs.RepoPath("alice", "demo")
193
+	if err != nil {
194
+		t.Fatalf("RepoPath: %v", err)
195
+	}
196
+	seedRepoWithWorkflow(t, gitDir, map[string]string{
197
+		".shithub/workflows/ci.yml": ciYAML,
198
+	})
199
+	// repo:write scope needed for dispatch; seedBranchesEnv mints repo:read.
200
+	writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite))
201
+
202
+	body, _ := json.Marshal(map[string]any{
203
+		"ref": "trunk",
204
+		"inputs": map[string]string{
205
+			"env":   "prod",
206
+			"debug": "true",
207
+		},
208
+	})
209
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/actions/workflows/ci.yml/dispatches", bytes.NewReader(body))
210
+	req.Header.Set("Authorization", "Bearer "+writeToken)
211
+	req.Header.Set("Content-Type", "application/json")
212
+	rr := httptest.NewRecorder()
213
+	router.ServeHTTP(rr, req)
214
+	if rr.Code != http.StatusNoContent {
215
+		t.Fatalf("status: got %d, want 204; body=%s", rr.Code, rr.Body.String())
216
+	}
217
+}
218
+
219
+func TestActionsWorkflows_DispatchRejectsBadInput(t *testing.T) {
220
+	pool, router, rfs, _, _, _ := seedBranchesEnv(t, "alice")
221
+	gitDir, err := rfs.RepoPath("alice", "demo")
222
+	if err != nil {
223
+		t.Fatalf("RepoPath: %v", err)
224
+	}
225
+	seedRepoWithWorkflow(t, gitDir, map[string]string{
226
+		".shithub/workflows/ci.yml": ciYAML,
227
+	})
228
+	writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite))
229
+
230
+	body, _ := json.Marshal(map[string]any{
231
+		"inputs": map[string]string{"env": "staging"}, // not in choice options
232
+	})
233
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/actions/workflows/ci.yml/dispatches", bytes.NewReader(body))
234
+	req.Header.Set("Authorization", "Bearer "+writeToken)
235
+	req.Header.Set("Content-Type", "application/json")
236
+	rr := httptest.NewRecorder()
237
+	router.ServeHTTP(rr, req)
238
+	if rr.Code != http.StatusBadRequest {
239
+		t.Fatalf("status: got %d, want 400; body=%s", rr.Code, rr.Body.String())
240
+	}
241
+}
242
+
243
+func TestActionsWorkflows_DispatchRejectsWorkflowWithoutDispatch(t *testing.T) {
244
+	pool, router, rfs, _, _, _ := seedBranchesEnv(t, "alice")
245
+	gitDir, err := rfs.RepoPath("alice", "demo")
246
+	if err != nil {
247
+		t.Fatalf("RepoPath: %v", err)
248
+	}
249
+	seedRepoWithWorkflow(t, gitDir, map[string]string{
250
+		".shithub/workflows/push.yml": noDispatchYAML,
251
+	})
252
+	writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite))
253
+
254
+	body, _ := json.Marshal(map[string]any{})
255
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/actions/workflows/push.yml/dispatches", bytes.NewReader(body))
256
+	req.Header.Set("Authorization", "Bearer "+writeToken)
257
+	req.Header.Set("Content-Type", "application/json")
258
+	rr := httptest.NewRecorder()
259
+	router.ServeHTTP(rr, req)
260
+	if rr.Code != http.StatusBadRequest {
261
+		t.Fatalf("status: got %d, want 400; body=%s", rr.Code, rr.Body.String())
262
+	}
263
+}
264
+
265
+func TestActionsWorkflows_DispatchRequiresRepoWrite(t *testing.T) {
266
+	_, router, rfs, token, _, _ := seedBranchesEnv(t, "alice")
267
+	gitDir, err := rfs.RepoPath("alice", "demo")
268
+	if err != nil {
269
+		t.Fatalf("RepoPath: %v", err)
270
+	}
271
+	seedRepoWithWorkflow(t, gitDir, map[string]string{
272
+		".shithub/workflows/ci.yml": ciYAML,
273
+	})
274
+	// token from seedBranchesEnv is repo:read only.
275
+	body, _ := json.Marshal(map[string]any{})
276
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/actions/workflows/ci.yml/dispatches", bytes.NewReader(body))
277
+	req.Header.Set("Authorization", "Bearer "+token)
278
+	req.Header.Set("Content-Type", "application/json")
279
+	rr := httptest.NewRecorder()
280
+	router.ServeHTTP(rr, req)
281
+	if rr.Code != http.StatusForbidden {
282
+		t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
283
+	}
284
+}
285
+
286
+func TestActionsWorkflows_DispatchRejectsUnknownWorkflow(t *testing.T) {
287
+	pool, router, rfs, _, _, _ := seedBranchesEnv(t, "alice")
288
+	gitDir, err := rfs.RepoPath("alice", "demo")
289
+	if err != nil {
290
+		t.Fatalf("RepoPath: %v", err)
291
+	}
292
+	seedRepoWithWorkflow(t, gitDir, map[string]string{
293
+		".shithub/workflows/ci.yml": ciYAML,
294
+	})
295
+	writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite))
296
+
297
+	body, _ := json.Marshal(map[string]any{})
298
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/actions/workflows/ghost.yml/dispatches", bytes.NewReader(body))
299
+	req.Header.Set("Authorization", "Bearer "+writeToken)
300
+	req.Header.Set("Content-Type", "application/json")
301
+	rr := httptest.NewRecorder()
302
+	router.ServeHTTP(rr, req)
303
+	if rr.Code != http.StatusNotFound {
304
+		t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String())
305
+	}
306
+}