Go · 14567 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repos_test
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "log/slog"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "testing"
16 "time"
17
18 "github.com/jackc/pgx/v5/pgtype"
19 "github.com/jackc/pgx/v5/pgxpool"
20
21 "github.com/tenseleyFlow/shithub/internal/auth/audit"
22 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
23 "github.com/tenseleyFlow/shithub/internal/infra/storage"
24 "github.com/tenseleyFlow/shithub/internal/orgs"
25 "github.com/tenseleyFlow/shithub/internal/repos"
26 "github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
27 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
28 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
29 )
30
31 // gitCmd wraps exec.Command with the single G204 suppression for this
32 // file — every git invocation runs against a t.TempDir path.
33 func gitCmd(args ...string) *exec.Cmd {
34 //nolint:gosec // G204 false positive: callers feed t.TempDir paths and fixed flags.
35 return exec.Command("git", args...)
36 }
37
38 // fixtureHash is a static PHC test fixture (zero salt, zero key) — not a credential.
39 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
40 "AAAAAAAAAAAAAAAA$" +
41 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
42
43 // setupCreateEnv constructs Deps + a verified-email user against a
44 // fresh test DB.
45 func setupCreateEnv(t *testing.T) (*pgxpool.Pool, repos.Deps, int64, string, string) {
46 t.Helper()
47 pool := dbtest.NewTestDB(t)
48
49 root := t.TempDir()
50 rfs, err := storage.NewRepoFS(root)
51 if err != nil {
52 t.Fatalf("NewRepoFS: %v", err)
53 }
54
55 deps := repos.Deps{
56 Pool: pool,
57 RepoFS: rfs,
58 Audit: audit.NewRecorder(),
59 Limiter: throttle.NewLimiter(),
60 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
61 }
62
63 uq := usersdb.New()
64 user, err := uq.CreateUser(context.Background(), pool, usersdb.CreateUserParams{
65 Username: "alice", DisplayName: "Alice Anderson", PasswordHash: fixtureHash,
66 })
67 if err != nil {
68 t.Fatalf("CreateUser: %v", err)
69 }
70 em, err := uq.CreateUserEmail(context.Background(), pool, usersdb.CreateUserEmailParams{
71 UserID: user.ID, Email: "alice@example.com", IsPrimary: true, Verified: true,
72 })
73 if err != nil {
74 t.Fatalf("CreateUserEmail: %v", err)
75 }
76 if err := uq.LinkUserPrimaryEmail(context.Background(), pool, usersdb.LinkUserPrimaryEmailParams{
77 ID: user.ID, PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
78 }); err != nil {
79 t.Fatalf("LinkUserPrimaryEmail: %v", err)
80 }
81 return pool, deps, user.ID, user.Username, root
82 }
83
84 func TestCreate_EmptyRepo(t *testing.T) {
85 t.Parallel()
86 _, deps, uid, uname, root := setupCreateEnv(t)
87 res, err := repos.Create(context.Background(), deps, repos.Params{
88 OwnerUserID: uid,
89 OwnerUsername: uname,
90 Name: "empty-repo",
91 Visibility: "public",
92 })
93 if err != nil {
94 t.Fatalf("Create: %v", err)
95 }
96 if res.InitialCommitOID != "" {
97 t.Errorf("expected no initial commit; got %q", res.InitialCommitOID)
98 }
99 if !strings.HasPrefix(res.DiskPath, root) {
100 t.Errorf("DiskPath %q not under root %q", res.DiskPath, root)
101 }
102
103 // HEAD must be a symbolic ref to refs/heads/trunk (unborn branch).
104 out, err := gitCmd("-C", res.DiskPath, "symbolic-ref", "HEAD").CombinedOutput()
105 if err != nil {
106 t.Fatalf("symbolic-ref: %v\n%s", err, out)
107 }
108 if got := strings.TrimSpace(string(out)); got != "refs/heads/trunk" {
109 t.Fatalf("HEAD = %q, want refs/heads/trunk", got)
110 }
111
112 // Zero commits.
113 out, _ = gitCmd("-C", res.DiskPath, "rev-list", "--all", "--count").CombinedOutput()
114 if got := strings.TrimSpace(string(out)); got != "0" {
115 t.Fatalf("rev-list count = %q, want 0", got)
116 }
117 }
118
119 func TestCreate_WithReadmeLicenseGitignore(t *testing.T) {
120 t.Parallel()
121 _, deps, uid, uname, _ := setupCreateEnv(t)
122 res, err := repos.Create(context.Background(), deps, repos.Params{
123 OwnerUserID: uid,
124 OwnerUsername: uname,
125 Name: "init-repo",
126 Description: "hello world",
127 Visibility: "public",
128 InitReadme: true,
129 LicenseKey: "MIT",
130 GitignoreKey: "Go",
131 InitialCommitWhen: time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC),
132 })
133 if err != nil {
134 t.Fatalf("Create: %v", err)
135 }
136 if res.InitialCommitOID == "" {
137 t.Fatal("expected an initial commit")
138 }
139
140 // Single commit, three files, expected names.
141 out, _ := gitCmd("-C", res.DiskPath, "rev-list", "--count", "trunk").CombinedOutput()
142 if got := strings.TrimSpace(string(out)); got != "1" {
143 t.Fatalf("rev-list count = %q, want 1", got)
144 }
145 out, _ = gitCmd("-C", res.DiskPath, "ls-tree", "--name-only", "trunk").CombinedOutput()
146 got := strings.TrimSpace(string(out))
147 for _, want := range []string{"README.md", "LICENSE", ".gitignore"} {
148 if !strings.Contains(got, want) {
149 t.Errorf("missing %q in tree: %q", want, got)
150 }
151 }
152 // Author identity is alice's verified primary email.
153 out, _ = gitCmd("-C", res.DiskPath, "log", "-1", "--format=%an <%ae>", "trunk").CombinedOutput()
154 if want := "Alice Anderson <alice@example.com>"; strings.TrimSpace(string(out)) != want {
155 t.Errorf("author = %q, want %q", strings.TrimSpace(string(out)), want)
156 }
157 // LICENSE has the year substituted.
158 out, _ = gitCmd("-C", res.DiskPath, "show", "trunk:LICENSE").CombinedOutput()
159 if !strings.Contains(string(out), "2026") {
160 t.Errorf("LICENSE missing year 2026; got first 200 chars: %s", string(out)[:200])
161 }
162 }
163
164 func TestCreate_RejectsDuplicate(t *testing.T) {
165 t.Parallel()
166 _, deps, uid, uname, _ := setupCreateEnv(t)
167 if _, err := repos.Create(context.Background(), deps, repos.Params{
168 OwnerUserID: uid, OwnerUsername: uname, Name: "dup", Visibility: "public",
169 }); err != nil {
170 t.Fatalf("first create: %v", err)
171 }
172 _, err := repos.Create(context.Background(), deps, repos.Params{
173 OwnerUserID: uid, OwnerUsername: uname, Name: "dup", Visibility: "public",
174 })
175 if !errors.Is(err, repos.ErrTaken) {
176 t.Fatalf("second create: err = %v, want ErrTaken", err)
177 }
178 }
179
180 func TestCreate_ReusesSoftDeletedRepoName(t *testing.T) {
181 t.Parallel()
182 pool, deps, uid, uname, _ := setupCreateEnv(t)
183 ctx := context.Background()
184 first, err := repos.Create(ctx, deps, repos.Params{
185 OwnerUserID: uid, OwnerUsername: uname, Name: "reuse", Visibility: "public",
186 InitReadme: true,
187 })
188 if err != nil {
189 t.Fatalf("first create: %v", err)
190 }
191 deletedPath, err := deps.RepoFS.DeletedRepoPath(uname, "reuse", first.Repo.ID)
192 if err != nil {
193 t.Fatalf("DeletedRepoPath: %v", err)
194 }
195 ldeps := lifecycle.Deps{Pool: pool, RepoFS: deps.RepoFS, Audit: audit.NewRecorder(), Logger: deps.Logger}
196 if err := lifecycle.SoftDelete(ctx, ldeps, uid, first.Repo.ID); err != nil {
197 t.Fatalf("SoftDelete: %v", err)
198 }
199 if _, err := os.Stat(first.DiskPath); !os.IsNotExist(err) {
200 t.Fatalf("canonical path after soft-delete: err = %v, want not exist", err)
201 }
202 if _, err := os.Stat(deletedPath); err != nil {
203 t.Fatalf("deleted tombstone missing: %v", err)
204 }
205
206 second, err := repos.Create(ctx, deps, repos.Params{
207 OwnerUserID: uid, OwnerUsername: uname, Name: "reuse", Visibility: "public",
208 })
209 if err != nil {
210 t.Fatalf("recreate: %v", err)
211 }
212 if second.Repo.ID == first.Repo.ID {
213 t.Fatalf("recreate reused old repo id %d", second.Repo.ID)
214 }
215 if _, err := os.Stat(second.DiskPath); err != nil {
216 t.Fatalf("replacement canonical path missing: %v", err)
217 }
218
219 if _, err := pool.Exec(ctx,
220 `UPDATE repos SET deleted_at = now() - interval '8 days' WHERE id = $1`, first.Repo.ID); err != nil {
221 t.Fatalf("backdate deleted repo: %v", err)
222 }
223 if err := lifecycle.HardDelete(ctx, ldeps, 0, first.Repo.ID); err != nil {
224 t.Fatalf("HardDelete old repo: %v", err)
225 }
226 if _, err := os.Stat(second.DiskPath); err != nil {
227 t.Fatalf("replacement path should survive hard-delete of old repo: %v", err)
228 }
229 }
230
231 func TestCreate_DisplacesLegacySoftDeletedOrgRepoPath(t *testing.T) {
232 t.Parallel()
233 pool, deps, uid, _, _ := setupCreateEnv(t)
234 ctx := context.Background()
235 org, err := orgs.Create(ctx, orgs.Deps{Pool: pool}, orgs.CreateParams{
236 Slug: "gardesk", DisplayName: "gardesk", CreatedByUserID: uid,
237 })
238 if err != nil {
239 t.Fatalf("orgs.Create: %v", err)
240 }
241 first, err := repos.Create(ctx, deps, repos.Params{
242 OwnerOrgID: org.ID, OwnerSlug: string(org.Slug), ActorUserID: uid,
243 Name: "garterm", Visibility: "public", InitReadme: true,
244 })
245 if err != nil {
246 t.Fatalf("first create: %v", err)
247 }
248 if _, err := pool.Exec(ctx,
249 `UPDATE repos SET deleted_at = now(), updated_at = now() WHERE id = $1`, first.Repo.ID); err != nil {
250 t.Fatalf("legacy soft delete: %v", err)
251 }
252
253 second, err := repos.Create(ctx, deps, repos.Params{
254 OwnerOrgID: org.ID, OwnerSlug: string(org.Slug), ActorUserID: uid,
255 Name: "garterm", Visibility: "public",
256 })
257 if err != nil {
258 t.Fatalf("recreate after legacy soft delete: %v", err)
259 }
260 if second.Repo.ID == first.Repo.ID {
261 t.Fatalf("recreate reused old repo id %d", second.Repo.ID)
262 }
263 deletedPath, err := deps.RepoFS.DeletedRepoPath(string(org.Slug), "garterm", first.Repo.ID)
264 if err != nil {
265 t.Fatalf("DeletedRepoPath: %v", err)
266 }
267 if _, err := os.Stat(deletedPath); err != nil {
268 t.Fatalf("legacy repo was not moved to tombstone: %v", err)
269 }
270 if _, err := os.Stat(second.DiskPath); err != nil {
271 t.Fatalf("replacement canonical path missing: %v", err)
272 }
273 }
274
275 func TestCreate_RejectsReservedName(t *testing.T) {
276 t.Parallel()
277 _, deps, uid, uname, _ := setupCreateEnv(t)
278 _, err := repos.Create(context.Background(), deps, repos.Params{
279 OwnerUserID: uid, OwnerUsername: uname, Name: "head", Visibility: "public",
280 })
281 if !errors.Is(err, repos.ErrReservedName) {
282 t.Fatalf("err = %v, want ErrReservedName", err)
283 }
284 }
285
286 func TestCreate_RefusesWithoutVerifiedEmail(t *testing.T) {
287 t.Parallel()
288 pool := dbtest.NewTestDB(t)
289 root := t.TempDir()
290 rfs, _ := storage.NewRepoFS(root)
291 deps := repos.Deps{
292 Pool: pool, RepoFS: rfs,
293 Audit: audit.NewRecorder(),
294 Limiter: throttle.NewLimiter(),
295 }
296 uq := usersdb.New()
297 user, err := uq.CreateUser(context.Background(), pool, usersdb.CreateUserParams{
298 Username: "bob", DisplayName: "Bob", PasswordHash: fixtureHash,
299 })
300 if err != nil {
301 t.Fatalf("CreateUser: %v", err)
302 }
303 // User exists but has NO verified primary email.
304 _, err = repos.Create(context.Background(), deps, repos.Params{
305 OwnerUserID: user.ID, OwnerUsername: user.Username,
306 Name: "needs-email", Visibility: "public",
307 InitReadme: true,
308 })
309 if !errors.Is(err, repos.ErrNoVerifiedEmail) {
310 t.Fatalf("err = %v, want ErrNoVerifiedEmail", err)
311 }
312 }
313
314 func TestCreate_PrivateVisibilityPersists(t *testing.T) {
315 t.Parallel()
316 _, deps, uid, uname, _ := setupCreateEnv(t)
317 res, err := repos.Create(context.Background(), deps, repos.Params{
318 OwnerUserID: uid, OwnerUsername: uname,
319 Name: "secret", Visibility: "private",
320 })
321 if err != nil {
322 t.Fatalf("Create: %v", err)
323 }
324 if string(res.Repo.Visibility) != "private" {
325 t.Errorf("Visibility = %q, want private", res.Repo.Visibility)
326 }
327 // Sharded path layout sanity check (shard is first 2 chars of OWNER).
328 if want := filepath.Join("al", "alice", "secret.git"); !strings.HasSuffix(res.DiskPath, want) {
329 t.Errorf("DiskPath %q does not end with %q", res.DiskPath, want)
330 }
331 }
332
333 func TestCreate_OrgOwned(t *testing.T) {
334 t.Parallel()
335 pool, deps, uid, _, _ := setupCreateEnv(t)
336 // Create an org owned by alice (the setupCreateEnv user).
337 odeps := orgs.Deps{Pool: pool}
338 org, err := orgs.Create(context.Background(), odeps, orgs.CreateParams{
339 Slug: "acme", DisplayName: "Acme", CreatedByUserID: uid,
340 })
341 if err != nil {
342 t.Fatalf("orgs.Create: %v", err)
343 }
344 res, err := repos.Create(context.Background(), deps, repos.Params{
345 OwnerOrgID: org.ID,
346 OwnerSlug: string(org.Slug),
347 ActorUserID: uid,
348 Name: "demo",
349 Visibility: "public",
350 })
351 if err != nil {
352 t.Fatalf("Create org-owned: %v", err)
353 }
354 if !res.Repo.OwnerOrgID.Valid || res.Repo.OwnerOrgID.Int64 != org.ID {
355 t.Fatalf("expected org-owned row; got owner_org_id=%v", res.Repo.OwnerOrgID)
356 }
357 if res.Repo.OwnerUserID.Valid {
358 t.Fatalf("owner_user_id should be NULL for org-owned, got %v", res.Repo.OwnerUserID)
359 }
360 // Disk path uses the org slug, not a user-namespace prefix.
361 if !strings.Contains(res.DiskPath, "acme") {
362 t.Fatalf("DiskPath %q should contain org slug 'acme'", res.DiskPath)
363 }
364 }
365
366 // TestCreate_ThrottlesNonAdmin saturates the per-actor cap directly via
367 // the limiter and confirms a non-admin Create call returns the typed
368 // throttle error. Doing it this way avoids spinning up
369 // CreateRateLimitMax+1 real repositories.
370 func TestCreate_ThrottlesNonAdmin(t *testing.T) {
371 t.Parallel()
372 _, deps, uid, uname, _ := setupCreateEnv(t)
373 saturateCreateLimiter(t, deps, uid)
374
375 _, err := repos.Create(context.Background(), deps, repos.Params{
376 OwnerUserID: uid,
377 OwnerUsername: uname,
378 Name: "should-throttle",
379 Visibility: "public",
380 })
381 if !throttle.IsThrottled(err) {
382 t.Fatalf("Create: err = %v, want throttle error", err)
383 }
384 }
385
386 // TestCreate_SiteAdminBypassesThrottle is the bookend: same saturated
387 // counter, but with ActorIsSiteAdmin=true the create succeeds.
388 func TestCreate_SiteAdminBypassesThrottle(t *testing.T) {
389 t.Parallel()
390 _, deps, uid, uname, _ := setupCreateEnv(t)
391 saturateCreateLimiter(t, deps, uid)
392
393 res, err := repos.Create(context.Background(), deps, repos.Params{
394 OwnerUserID: uid,
395 OwnerUsername: uname,
396 ActorIsSiteAdmin: true,
397 Name: "admin-bypass",
398 Visibility: "public",
399 })
400 if err != nil {
401 t.Fatalf("Create with admin bypass: %v", err)
402 }
403 if res.Repo.Name != "admin-bypass" {
404 t.Fatalf("created repo name = %q, want admin-bypass", res.Repo.Name)
405 }
406 }
407
408 // saturateCreateLimiter pushes the per-actor counter for "repo_create"
409 // up to the cap so the next non-admin Hit returns ErrThrottled.
410 func saturateCreateLimiter(t *testing.T, deps repos.Deps, uid int64) {
411 t.Helper()
412 lim := throttle.Limit{
413 Scope: "repo_create",
414 Identifier: fmt.Sprintf("user:%d", uid),
415 Max: repos.CreateRateLimitMax,
416 Window: repos.CreateRateLimitWindow,
417 }
418 for i := 0; i < repos.CreateRateLimitMax; i++ {
419 if err := deps.Limiter.Hit(context.Background(), deps.Pool, lim); err != nil {
420 t.Fatalf("priming limiter hit %d: %v", i, err)
421 }
422 }
423 }
424
425 func TestCreate_RejectsBothOwnerKindsSet(t *testing.T) {
426 t.Parallel()
427 _, deps, uid, uname, _ := setupCreateEnv(t)
428 _, err := repos.Create(context.Background(), deps, repos.Params{
429 OwnerUserID: uid,
430 OwnerUsername: uname,
431 OwnerOrgID: 999, // both set — XOR violated
432 OwnerSlug: "x",
433 Name: "noop",
434 Visibility: "public",
435 })
436 if err == nil {
437 t.Fatal("expected XOR violation error, got nil")
438 }
439 }
440