Go · 9879 bytes Raw Blame History
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 }
307