Go · 9917 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/base64"
9 "encoding/json"
10 "net/http"
11 "net/http/httptest"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/tenseleyFlow/shithub/internal/auth/pat"
17 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
18 )
19
20 // ─── README ─────────────────────────────────────────────────────────
21
22 type apiReadme struct {
23 Name string `json:"name"`
24 Path string `json:"path"`
25 Size int64 `json:"size"`
26 Encoding string `json:"encoding"`
27 Content string `json:"content"`
28 DownloadURL string `json:"download_url"`
29 }
30
31 func seedRepoWithFiles(t *testing.T, gitDir string, files map[string]string) {
32 t.Helper()
33 entries := make([]repogit.FileEntry, 0, len(files))
34 for path, body := range files {
35 entries = append(entries, repogit.FileEntry{Path: path, Body: []byte(body)})
36 }
37 if _, err := (repogit.InitialCommit{
38 GitDir: gitDir,
39 AuthorName: "Alice",
40 AuthorEmail: "alice@example.test",
41 Branch: "trunk",
42 Message: "init",
43 When: time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC),
44 Files: entries,
45 }).Build(context.Background()); err != nil {
46 t.Fatalf("InitialCommit.Build: %v", err)
47 }
48 }
49
50 func TestRepoReadme_PreferredMarkdown(t *testing.T) {
51 _, router, rfs, token, _, _ := seedBranchesEnv(t, "alice")
52 gitDir, err := rfs.RepoPath("alice", "demo")
53 if err != nil {
54 t.Fatalf("RepoPath: %v", err)
55 }
56 seedRepoWithFiles(t, gitDir, map[string]string{
57 "README.md": "# Demo repo\n\nHello!\n",
58 "README.rst": "Demo repo\n=========\n", // present but should not win over .md
59 })
60
61 req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/readme", nil)
62 req.Header.Set("Authorization", "Bearer "+token)
63 rr := httptest.NewRecorder()
64 router.ServeHTTP(rr, req)
65 if rr.Code != http.StatusOK {
66 t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
67 }
68 var got apiReadme
69 if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
70 t.Fatalf("decode: %v", err)
71 }
72 if got.Name != "README.md" {
73 t.Errorf("name: got %q, want README.md", got.Name)
74 }
75 if got.Encoding != "base64" {
76 t.Errorf("encoding: got %q", got.Encoding)
77 }
78 decoded, err := base64.StdEncoding.DecodeString(got.Content)
79 if err != nil {
80 t.Fatalf("decode content: %v", err)
81 }
82 if !strings.Contains(string(decoded), "Demo repo") {
83 t.Errorf("content round-trip: %q", decoded)
84 }
85 if got.Size != int64(len(decoded)) {
86 t.Errorf("size mismatch: header=%d, decoded=%d", got.Size, len(decoded))
87 }
88 if !strings.HasSuffix(got.DownloadURL, "/raw/trunk/README.md") {
89 t.Errorf("download_url: %q", got.DownloadURL)
90 }
91 }
92
93 func TestRepoReadme_FallbackToNonMarkdown(t *testing.T) {
94 _, router, rfs, token, _, _ := seedBranchesEnv(t, "alice")
95 gitDir, err := rfs.RepoPath("alice", "demo")
96 if err != nil {
97 t.Fatalf("RepoPath: %v", err)
98 }
99 seedRepoWithFiles(t, gitDir, map[string]string{
100 "README.rst": "Demo repo (rst)\n=========\n",
101 })
102
103 req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/readme", nil)
104 req.Header.Set("Authorization", "Bearer "+token)
105 rr := httptest.NewRecorder()
106 router.ServeHTTP(rr, req)
107 if rr.Code != http.StatusOK {
108 t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
109 }
110 var got apiReadme
111 _ = json.Unmarshal(rr.Body.Bytes(), &got)
112 if got.Name != "README.rst" {
113 t.Errorf("name: got %q, want README.rst", got.Name)
114 }
115 }
116
117 func TestRepoReadme_NoREADMEReturns404(t *testing.T) {
118 _, router, rfs, token, _, _ := seedBranchesEnv(t, "alice")
119 gitDir, err := rfs.RepoPath("alice", "demo")
120 if err != nil {
121 t.Fatalf("RepoPath: %v", err)
122 }
123 seedRepoWithFiles(t, gitDir, map[string]string{
124 "main.go": "package main\nfunc main() {}\n",
125 })
126
127 req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/readme", nil)
128 req.Header.Set("Authorization", "Bearer "+token)
129 rr := httptest.NewRecorder()
130 router.ServeHTTP(rr, req)
131 if rr.Code != http.StatusNotFound {
132 t.Fatalf("status: got %d, want 404; body=%s", rr.Code, rr.Body.String())
133 }
134 }
135
136 func TestRepoReadme_AnonReadOnPublicRepo(t *testing.T) {
137 _, router, rfs, _, _, _ := seedBranchesEnv(t, "alice")
138 gitDir, err := rfs.RepoPath("alice", "demo")
139 if err != nil {
140 t.Fatalf("RepoPath: %v", err)
141 }
142 seedRepoWithFiles(t, gitDir, map[string]string{
143 "README.md": "# Demo\n",
144 })
145 // No Authorization header — public repo, ActionRepoRead allows anon.
146 req := httptest.NewRequest(http.MethodGet, "/api/v1/repos/alice/demo/readme", nil)
147 rr := httptest.NewRecorder()
148 router.ServeHTTP(rr, req)
149 if rr.Code != http.StatusOK {
150 t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
151 }
152 }
153
154 // ─── topics ─────────────────────────────────────────────────────────
155
156 func TestRepoTopics_ReplaceThenList(t *testing.T) {
157 pool, router, _, _, _, _ := seedBranchesEnv(t, "alice")
158 writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite))
159
160 body, _ := json.Marshal(map[string][]string{"names": {"go", "rest-api", "shithub"}})
161 req := httptest.NewRequest(http.MethodPut, "/api/v1/repos/alice/demo/topics", bytes.NewReader(body))
162 req.Header.Set("Authorization", "Bearer "+writeToken)
163 req.Header.Set("Content-Type", "application/json")
164 rr := httptest.NewRecorder()
165 router.ServeHTTP(rr, req)
166 if rr.Code != http.StatusOK {
167 t.Fatalf("status: got %d; body=%s", rr.Code, rr.Body.String())
168 }
169 var got map[string][]string
170 _ = json.Unmarshal(rr.Body.Bytes(), &got)
171 want := map[bool]struct{}{}
172 for _, n := range got["names"] {
173 want[n == "go" || n == "rest-api" || n == "shithub"] = struct{}{}
174 }
175 if len(got["names"]) != 3 {
176 t.Errorf("names count: got %v", got["names"])
177 }
178 }
179
180 func TestRepoTopics_ReplaceRejectsInvalidShape(t *testing.T) {
181 pool, router, _, _, _, _ := seedBranchesEnv(t, "alice")
182 writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite))
183
184 body, _ := json.Marshal(map[string][]string{"names": {"UPPERCASE_NOT_OK"}})
185 req := httptest.NewRequest(http.MethodPut, "/api/v1/repos/alice/demo/topics", bytes.NewReader(body))
186 req.Header.Set("Authorization", "Bearer "+writeToken)
187 req.Header.Set("Content-Type", "application/json")
188 rr := httptest.NewRecorder()
189 router.ServeHTTP(rr, req)
190 if rr.Code != http.StatusUnprocessableEntity {
191 t.Fatalf("status: got %d, want 422; body=%s", rr.Code, rr.Body.String())
192 }
193 }
194
195 func TestRepoTopics_Clear(t *testing.T) {
196 pool, router, _, _, _, _ := seedBranchesEnv(t, "alice")
197 writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite))
198
199 // Seed two topics.
200 body, _ := json.Marshal(map[string][]string{"names": {"go", "rest"}})
201 req := httptest.NewRequest(http.MethodPut, "/api/v1/repos/alice/demo/topics", bytes.NewReader(body))
202 req.Header.Set("Authorization", "Bearer "+writeToken)
203 req.Header.Set("Content-Type", "application/json")
204 router.ServeHTTP(httptest.NewRecorder(), req)
205
206 // Clear.
207 req = httptest.NewRequest(http.MethodDelete, "/api/v1/repos/alice/demo/topics", nil)
208 req.Header.Set("Authorization", "Bearer "+writeToken)
209 rr := httptest.NewRecorder()
210 router.ServeHTTP(rr, req)
211 if rr.Code != http.StatusNoContent {
212 t.Fatalf("clear status: got %d; body=%s", rr.Code, rr.Body.String())
213 }
214
215 // Verify empty via a second PUT with empty names returning shape.
216 body, _ = json.Marshal(map[string][]string{"names": {}})
217 req = httptest.NewRequest(http.MethodPut, "/api/v1/repos/alice/demo/topics", bytes.NewReader(body))
218 req.Header.Set("Authorization", "Bearer "+writeToken)
219 req.Header.Set("Content-Type", "application/json")
220 rr = httptest.NewRecorder()
221 router.ServeHTTP(rr, req)
222 var got map[string][]string
223 _ = json.Unmarshal(rr.Body.Bytes(), &got)
224 if len(got["names"]) != 0 {
225 t.Errorf("names after clear: got %v", got["names"])
226 }
227 }
228
229 func TestRepoTopics_ReplaceRequiresRepoWrite(t *testing.T) {
230 _, router, _, token, _, _ := seedBranchesEnv(t, "alice")
231 body, _ := json.Marshal(map[string][]string{"names": {"go"}})
232 req := httptest.NewRequest(http.MethodPut, "/api/v1/repos/alice/demo/topics", bytes.NewReader(body))
233 req.Header.Set("Authorization", "Bearer "+token) // repo:read only
234 req.Header.Set("Content-Type", "application/json")
235 rr := httptest.NewRecorder()
236 router.ServeHTTP(rr, req)
237 if rr.Code != http.StatusForbidden {
238 t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
239 }
240 }
241
242 // ─── merge-upstream ─────────────────────────────────────────────────
243
244 func TestRepoMergeUpstream_NotAForkReturns422(t *testing.T) {
245 pool, router, _, _, _, _ := seedBranchesEnv(t, "alice")
246 writeToken := mintRunnerAPIPAT(t, pool, ownerIDForAlice(t, pool), string(pat.ScopeRepoWrite))
247
248 req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/merge-upstream", strings.NewReader("{}"))
249 req.Header.Set("Authorization", "Bearer "+writeToken)
250 req.Header.Set("Content-Type", "application/json")
251 rr := httptest.NewRecorder()
252 router.ServeHTTP(rr, req)
253 if rr.Code != http.StatusUnprocessableEntity {
254 t.Fatalf("status: got %d, want 422; body=%s", rr.Code, rr.Body.String())
255 }
256 }
257
258 func TestRepoMergeUpstream_RequiresRepoWrite(t *testing.T) {
259 _, router, _, token, _, _ := seedBranchesEnv(t, "alice")
260 req := httptest.NewRequest(http.MethodPost, "/api/v1/repos/alice/demo/merge-upstream", strings.NewReader("{}"))
261 req.Header.Set("Authorization", "Bearer "+token) // repo:read only
262 req.Header.Set("Content-Type", "application/json")
263 rr := httptest.NewRecorder()
264 router.ServeHTTP(rr, req)
265 if rr.Code != http.StatusForbidden {
266 t.Fatalf("status: got %d, want 403; body=%s", rr.Code, rr.Body.String())
267 }
268 }
269