Go · 15131 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package lifecycle_test
4
5 import (
6 "context"
7 "errors"
8 "os"
9 "path/filepath"
10 "testing"
11 "time"
12
13 "github.com/jackc/pgx/v5/pgtype"
14
15 "github.com/tenseleyFlow/shithub/internal/auth/audit"
16 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
17 "github.com/tenseleyFlow/shithub/internal/entitlements"
18 "github.com/tenseleyFlow/shithub/internal/infra/storage"
19 "github.com/tenseleyFlow/shithub/internal/orgs"
20 "github.com/tenseleyFlow/shithub/internal/repos"
21 "github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
22 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
23 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
24 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25 )
26
27 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
28 "AAAAAAAAAAAAAAAA$" +
29 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
30
31 type env struct {
32 deps lifecycle.Deps
33 rdeps repos.Deps
34
35 alice usersdb.User
36 bob usersdb.User
37
38 repoID int64
39 originalFS string
40 }
41
42 func setup(t *testing.T) *env {
43 t.Helper()
44 pool := dbtest.NewTestDB(t)
45 root := t.TempDir()
46 rfs, err := storage.NewRepoFS(root)
47 if err != nil {
48 t.Fatalf("NewRepoFS: %v", err)
49 }
50 uq := usersdb.New()
51 mk := func(name string) usersdb.User {
52 u, err := uq.CreateUser(context.Background(), pool, usersdb.CreateUserParams{
53 Username: name, DisplayName: name, PasswordHash: fixtureHash,
54 })
55 if err != nil {
56 t.Fatalf("CreateUser %s: %v", name, err)
57 }
58 em, err := uq.CreateUserEmail(context.Background(), pool, usersdb.CreateUserEmailParams{
59 UserID: u.ID, Email: name + "@example.com", IsPrimary: true, Verified: true,
60 })
61 if err != nil {
62 t.Fatalf("CreateUserEmail %s: %v", name, err)
63 }
64 _ = uq.LinkUserPrimaryEmail(context.Background(), pool, usersdb.LinkUserPrimaryEmailParams{
65 ID: u.ID, PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
66 })
67 return u
68 }
69 alice := mk("alice")
70 bob := mk("bob")
71
72 rdeps := repos.Deps{
73 Pool: pool, RepoFS: rfs, Audit: audit.NewRecorder(), Limiter: throttle.NewLimiter(),
74 }
75 res, err := repos.Create(context.Background(), rdeps, repos.Params{
76 OwnerUserID: alice.ID, OwnerUsername: alice.Username,
77 Name: "demo", Visibility: "public", InitReadme: true,
78 })
79 if err != nil {
80 t.Fatalf("repos.Create: %v", err)
81 }
82
83 return &env{
84 deps: lifecycle.Deps{Pool: pool, RepoFS: rfs, Audit: audit.NewRecorder()},
85 rdeps: rdeps,
86 alice: alice, bob: bob,
87 repoID: res.Repo.ID,
88 originalFS: res.DiskPath,
89 }
90 }
91
92 func mustLifecycleUser(t *testing.T, db usersdb.DBTX, username string) usersdb.User {
93 t.Helper()
94 user, err := usersdb.New().CreateUser(context.Background(), db, usersdb.CreateUserParams{
95 Username: username,
96 DisplayName: username,
97 PasswordHash: fixtureHash,
98 })
99 if err != nil {
100 t.Fatalf("CreateUser %s: %v", username, err)
101 }
102 return user
103 }
104
105 func TestRename_HappyPath(t *testing.T) {
106 t.Parallel()
107 env := setup(t)
108 if err := lifecycle.Rename(context.Background(), env.deps, lifecycle.RenameParams{
109 ActorUserID: env.alice.ID,
110 RepoID: env.repoID,
111 OwnerUserID: env.alice.ID,
112 OwnerName: "alice",
113 OldName: "demo",
114 NewName: "renamed",
115 }); err != nil {
116 t.Fatalf("Rename: %v", err)
117 }
118 rq := reposdb.New()
119 repo, err := rq.GetRepoByID(context.Background(), env.deps.Pool, env.repoID)
120 if err != nil {
121 t.Fatalf("GetRepoByID: %v", err)
122 }
123 if repo.Name != "renamed" {
124 t.Errorf("name = %q, want renamed", repo.Name)
125 }
126 // Redirect row exists.
127 rid, err := rq.LookupRedirectByUserOwner(context.Background(), env.deps.Pool, reposdb.LookupRedirectByUserOwnerParams{
128 OldOwnerUserID: pgtype.Int8{Int64: env.alice.ID, Valid: true},
129 OldName: "demo",
130 })
131 if err != nil || rid != env.repoID {
132 t.Errorf("LookupRedirect: id=%d err=%v", rid, err)
133 }
134 // FS dir moved.
135 newPath := filepath.Join(filepath.Dir(env.originalFS), "renamed.git")
136 if _, err := os.Stat(newPath); err != nil {
137 t.Errorf("new path missing: %v", err)
138 }
139 }
140
141 func TestRename_RejectsSameName(t *testing.T) {
142 t.Parallel()
143 env := setup(t)
144 err := lifecycle.Rename(context.Background(), env.deps, lifecycle.RenameParams{
145 RepoID: env.repoID, OwnerUserID: env.alice.ID, OwnerName: "alice",
146 OldName: "demo", NewName: "demo",
147 })
148 if !errors.Is(err, lifecycle.ErrSameName) {
149 t.Errorf("err = %v, want ErrSameName", err)
150 }
151 }
152
153 func TestRename_RateLimit(t *testing.T) {
154 t.Parallel()
155 env := setup(t)
156 for i := 0; i < 5; i++ {
157 newName := []string{"a1", "a2", "a3", "a4", "a5"}[i]
158 oldName := "demo"
159 if i > 0 {
160 oldName = []string{"a1", "a2", "a3", "a4"}[i-1]
161 }
162 if err := lifecycle.Rename(context.Background(), env.deps, lifecycle.RenameParams{
163 RepoID: env.repoID, OwnerUserID: env.alice.ID, OwnerName: "alice",
164 OldName: oldName, NewName: newName,
165 }); err != nil {
166 t.Fatalf("rename %d: %v", i, err)
167 }
168 }
169 err := lifecycle.Rename(context.Background(), env.deps, lifecycle.RenameParams{
170 RepoID: env.repoID, OwnerUserID: env.alice.ID, OwnerName: "alice",
171 OldName: "a5", NewName: "a6",
172 })
173 if !errors.Is(err, lifecycle.ErrRenameRateLimited) {
174 t.Errorf("6th rename: err=%v, want ErrRenameRateLimited", err)
175 }
176 }
177
178 func TestArchiveUnarchive(t *testing.T) {
179 t.Parallel()
180 env := setup(t)
181 if err := lifecycle.Archive(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
182 t.Fatalf("Archive: %v", err)
183 }
184 if err := lifecycle.Archive(context.Background(), env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrAlreadyArchived) {
185 t.Errorf("double archive: err=%v, want ErrAlreadyArchived", err)
186 }
187 if err := lifecycle.Unarchive(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
188 t.Fatalf("Unarchive: %v", err)
189 }
190 if err := lifecycle.Unarchive(context.Background(), env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrNotArchived) {
191 t.Errorf("double unarchive: err=%v, want ErrNotArchived", err)
192 }
193 }
194
195 func TestSetVisibility(t *testing.T) {
196 t.Parallel()
197 env := setup(t)
198 if err := lifecycle.SetVisibility(context.Background(), env.deps, env.alice.ID, env.repoID, "private"); err != nil {
199 t.Fatalf("SetVisibility: %v", err)
200 }
201 rq := reposdb.New()
202 repo, _ := rq.GetRepoByID(context.Background(), env.deps.Pool, env.repoID)
203 if string(repo.Visibility) != "private" {
204 t.Errorf("Visibility = %q, want private", repo.Visibility)
205 }
206 if err := lifecycle.SetVisibility(context.Background(), env.deps, env.alice.ID, env.repoID, "bogus"); !errors.Is(err, lifecycle.ErrInvalidVisibility) {
207 t.Errorf("invalid: err=%v, want ErrInvalidVisibility", err)
208 }
209 }
210
211 func TestSetVisibilityRespectsPrivateCollaborationLimit(t *testing.T) {
212 t.Parallel()
213 env := setup(t)
214 ctx := context.Background()
215 org, err := orgs.Create(ctx, orgs.Deps{Pool: env.deps.Pool}, orgs.CreateParams{
216 Slug: "acme",
217 CreatedByUserID: env.alice.ID,
218 })
219 if err != nil {
220 t.Fatalf("create org: %v", err)
221 }
222 repo, err := reposdb.New().CreateRepo(ctx, env.deps.Pool, reposdb.CreateRepoParams{
223 OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
224 Name: "soon-private",
225 DefaultBranch: "trunk",
226 Visibility: reposdb.RepoVisibilityPublic,
227 })
228 if err != nil {
229 t.Fatalf("create org repo: %v", err)
230 }
231 for _, userID := range []int64{env.bob.ID, mustLifecycleUser(t, env.deps.Pool, "carol").ID, mustLifecycleUser(t, env.deps.Pool, "dave").ID} {
232 if _, err := env.deps.Pool.Exec(ctx, `INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')`, repo.ID, userID); err != nil {
233 t.Fatalf("insert collaborator: %v", err)
234 }
235 }
236 err = lifecycle.SetVisibility(ctx, env.deps, env.alice.ID, repo.ID, "private")
237 if !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
238 t.Fatalf("SetVisibility err=%v, want private collaboration limit", err)
239 }
240 }
241
242 func TestSoftDeleteAndRestore(t *testing.T) {
243 t.Parallel()
244 env := setup(t)
245 deletedPath, err := env.deps.RepoFS.DeletedRepoPath("alice", "demo", env.repoID)
246 if err != nil {
247 t.Fatalf("DeletedRepoPath: %v", err)
248 }
249 if err := lifecycle.SoftDelete(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
250 t.Fatalf("SoftDelete: %v", err)
251 }
252 if _, err := os.Stat(env.originalFS); !os.IsNotExist(err) {
253 t.Fatalf("canonical path after soft-delete: err = %v, want not exist", err)
254 }
255 if _, err := os.Stat(deletedPath); err != nil {
256 t.Fatalf("deleted tombstone missing: %v", err)
257 }
258 if err := lifecycle.SoftDelete(context.Background(), env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrAlreadyDeleted) {
259 t.Errorf("double soft-delete: err=%v", err)
260 }
261 if err := lifecycle.Restore(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
262 t.Fatalf("Restore: %v", err)
263 }
264 if _, err := os.Stat(env.originalFS); err != nil {
265 t.Fatalf("canonical path after restore missing: %v", err)
266 }
267 if _, err := os.Stat(deletedPath); !os.IsNotExist(err) {
268 t.Fatalf("deleted tombstone after restore: err = %v, want not exist", err)
269 }
270 if err := lifecycle.Restore(context.Background(), env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrNotDeleted) {
271 t.Errorf("double restore: err=%v", err)
272 }
273 }
274
275 func TestRestore_RefusesWhenNameReused(t *testing.T) {
276 t.Parallel()
277 env := setup(t)
278 ctx := context.Background()
279 if err := lifecycle.SoftDelete(ctx, env.deps, env.alice.ID, env.repoID); err != nil {
280 t.Fatalf("SoftDelete: %v", err)
281 }
282 replacement, err := repos.Create(ctx, env.rdeps, repos.Params{
283 OwnerUserID: env.alice.ID, OwnerUsername: env.alice.Username,
284 Name: "demo", Visibility: "public",
285 })
286 if err != nil {
287 t.Fatalf("replacement create: %v", err)
288 }
289 if err := lifecycle.Restore(ctx, env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrNameTaken) {
290 t.Fatalf("Restore: err = %v, want ErrNameTaken", err)
291 }
292 if _, err := os.Stat(replacement.DiskPath); err != nil {
293 t.Fatalf("replacement canonical path missing: %v", err)
294 }
295 deletedPath, err := env.deps.RepoFS.DeletedRepoPath("alice", "demo", env.repoID)
296 if err != nil {
297 t.Fatalf("DeletedRepoPath: %v", err)
298 }
299 if _, err := os.Stat(deletedPath); err != nil {
300 t.Fatalf("old tombstone should remain restorable: %v", err)
301 }
302 }
303
304 func TestRestore_PastGraceRefuses(t *testing.T) {
305 t.Parallel()
306 env := setup(t)
307 // Pin Now to 8 days from now after a soft-delete to simulate
308 // past-grace state.
309 if err := lifecycle.SoftDelete(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
310 t.Fatal(err)
311 }
312 env.deps.Now = func() time.Time { return time.Now().Add(8 * 24 * time.Hour) }
313 if err := lifecycle.Restore(context.Background(), env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrPastGrace) {
314 t.Errorf("past-grace restore: err=%v, want ErrPastGrace", err)
315 }
316 }
317
318 func TestTransfer_AcceptHappyPath(t *testing.T) {
319 t.Parallel()
320 env := setup(t)
321 id, err := lifecycle.RequestTransfer(context.Background(), env.deps, lifecycle.TransferRequestParams{
322 ActorUserID: env.alice.ID, RepoID: env.repoID,
323 FromUserID: env.alice.ID, ToPrincipalKind: "user", ToPrincipalID: env.bob.ID,
324 })
325 if err != nil {
326 t.Fatalf("RequestTransfer: %v", err)
327 }
328 if err := lifecycle.AcceptTransfer(context.Background(), env.deps, env.bob.ID, id); err != nil {
329 t.Fatalf("AcceptTransfer: %v", err)
330 }
331 rq := reposdb.New()
332 repo, _ := rq.GetRepoByID(context.Background(), env.deps.Pool, env.repoID)
333 if !repo.OwnerUserID.Valid || repo.OwnerUserID.Int64 != env.bob.ID {
334 t.Errorf("owner_user_id = %v, want %d", repo.OwnerUserID, env.bob.ID)
335 }
336 // Redirect from alice/demo → repo_id should resolve.
337 rid, err := rq.LookupRedirectByUserOwner(context.Background(), env.deps.Pool, reposdb.LookupRedirectByUserOwnerParams{
338 OldOwnerUserID: pgtype.Int8{Int64: env.alice.ID, Valid: true},
339 OldName: "demo",
340 })
341 if err != nil || rid != env.repoID {
342 t.Errorf("redirect: id=%d err=%v", rid, err)
343 }
344 }
345
346 func TestTransfer_DeclineLeavesOwnerUnchanged(t *testing.T) {
347 t.Parallel()
348 env := setup(t)
349 id, _ := lifecycle.RequestTransfer(context.Background(), env.deps, lifecycle.TransferRequestParams{
350 ActorUserID: env.alice.ID, RepoID: env.repoID,
351 FromUserID: env.alice.ID, ToPrincipalKind: "user", ToPrincipalID: env.bob.ID,
352 })
353 if err := lifecycle.DeclineTransfer(context.Background(), env.deps, env.bob.ID, id); err != nil {
354 t.Fatalf("DeclineTransfer: %v", err)
355 }
356 rq := reposdb.New()
357 repo, _ := rq.GetRepoByID(context.Background(), env.deps.Pool, env.repoID)
358 if !repo.OwnerUserID.Valid || repo.OwnerUserID.Int64 != env.alice.ID {
359 t.Errorf("owner changed despite decline: %v", repo.OwnerUserID)
360 }
361 }
362
363 func TestTransfer_CancelByOwner(t *testing.T) {
364 t.Parallel()
365 env := setup(t)
366 id, _ := lifecycle.RequestTransfer(context.Background(), env.deps, lifecycle.TransferRequestParams{
367 ActorUserID: env.alice.ID, RepoID: env.repoID,
368 FromUserID: env.alice.ID, ToPrincipalKind: "user", ToPrincipalID: env.bob.ID,
369 })
370 if err := lifecycle.CancelTransfer(context.Background(), env.deps, env.alice.ID, id); err != nil {
371 t.Fatalf("CancelTransfer: %v", err)
372 }
373 // Bob's accept now fails.
374 if err := lifecycle.AcceptTransfer(context.Background(), env.deps, env.bob.ID, id); !errors.Is(err, lifecycle.ErrTransferTerminal) {
375 t.Errorf("accept-after-cancel: err=%v, want ErrTransferTerminal", err)
376 }
377 }
378
379 func TestTransfer_ExpireSweepFlipsPending(t *testing.T) {
380 t.Parallel()
381 env := setup(t)
382 _, _ = lifecycle.RequestTransfer(context.Background(), env.deps, lifecycle.TransferRequestParams{
383 ActorUserID: env.alice.ID, RepoID: env.repoID,
384 FromUserID: env.alice.ID, ToPrincipalKind: "user", ToPrincipalID: env.bob.ID,
385 })
386 // Force the row past its expires_at by SQL update so we don't need
387 // to advance lifecycle.now() through the call path.
388 _, err := env.deps.Pool.Exec(context.Background(),
389 `UPDATE repo_transfer_requests SET expires_at = now() - interval '1 minute' WHERE repo_id = $1`, env.repoID)
390 if err != nil {
391 t.Fatalf("force expiry: %v", err)
392 }
393 n, err := lifecycle.ExpirePending(context.Background(), env.deps)
394 if err != nil {
395 t.Fatalf("ExpirePending: %v", err)
396 }
397 if n != 1 {
398 t.Errorf("expired count = %d, want 1", n)
399 }
400 }
401
402 func TestHardDelete_PastGraceCascades(t *testing.T) {
403 t.Parallel()
404 env := setup(t)
405 deletedPath, err := env.deps.RepoFS.DeletedRepoPath("alice", "demo", env.repoID)
406 if err != nil {
407 t.Fatalf("DeletedRepoPath: %v", err)
408 }
409 if err := lifecycle.SoftDelete(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
410 t.Fatal(err)
411 }
412 // Force deleted_at past grace and pin Now likewise.
413 _, _ = env.deps.Pool.Exec(context.Background(),
414 `UPDATE repos SET deleted_at = now() - interval '8 days' WHERE id = $1`, env.repoID)
415 env.deps.Now = time.Now
416
417 if err := lifecycle.HardDelete(context.Background(), env.deps, 0, env.repoID); err != nil {
418 t.Fatalf("HardDelete: %v", err)
419 }
420 // Repo row gone.
421 rq := reposdb.New()
422 if _, err := rq.GetRepoByID(context.Background(), env.deps.Pool, env.repoID); err == nil {
423 t.Errorf("repo row still present after hard-delete")
424 }
425 // Tombstone dir gone.
426 if _, err := os.Stat(deletedPath); !os.IsNotExist(err) {
427 t.Errorf("deleted tombstone still present: err=%v", err)
428 }
429 }
430