tenseleyflow/shithub / c8f60aa

Browse files

api/repos_followups: tests for README + topics + fork-sync

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
c8f60aac753a699a3836768031a034227774cc59
Parents
d6e0261
Tree
5a621f4

1 changed file

StatusFile+-
A internal/web/handlers/api/repos_followups_test.go 268 0
internal/web/handlers/api/repos_followups_test.goadded
@@ -0,0 +1,268 @@
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
+}