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