Go · 9632 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package orgs_test
4
5 import (
6 "context"
7 "errors"
8 "io"
9 "log/slog"
10 "testing"
11
12 "github.com/jackc/pgx/v5/pgtype"
13 "github.com/jackc/pgx/v5/pgxpool"
14
15 "github.com/tenseleyFlow/shithub/internal/orgs"
16 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
17 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
18 )
19
20 const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
21 "AAAAAAAAAAAAAAAA$" +
22 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
23
24 func setup(t *testing.T) (*pgxpool.Pool, orgs.Deps, int64) {
25 t.Helper()
26 pool := dbtest.NewTestDB(t)
27 ctx := context.Background()
28 u, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
29 Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
30 })
31 if err != nil {
32 t.Fatalf("create user: %v", err)
33 }
34 deps := orgs.Deps{
35 Pool: pool,
36 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
37 }
38 return pool, deps, u.ID
39 }
40
41 func mustUser(t *testing.T, pool *pgxpool.Pool, name string) int64 {
42 t.Helper()
43 u, err := usersdb.New().CreateUser(context.Background(), pool, usersdb.CreateUserParams{
44 Username: name, DisplayName: name, PasswordHash: fixtureHash,
45 })
46 if err != nil {
47 t.Fatalf("create user %s: %v", name, err)
48 }
49 return u.ID
50 }
51
52 func mustEmail(t *testing.T, pool *pgxpool.Pool, userID int64, addr string) {
53 t.Helper()
54 em, err := usersdb.New().CreateUserEmail(context.Background(), pool, usersdb.CreateUserEmailParams{
55 UserID: userID, Email: addr,
56 })
57 if err != nil {
58 t.Fatalf("create email: %v", err)
59 }
60 if err := usersdb.New().MarkUserEmailVerified(context.Background(), pool, em.ID); err != nil {
61 t.Fatalf("verify email: %v", err)
62 }
63 }
64
65 func TestCreate_HappyPath(t *testing.T) {
66 _, deps, alice := setup(t)
67 row, err := orgs.Create(context.Background(), deps, orgs.CreateParams{
68 Slug: "acme", DisplayName: "Acme Inc", CreatedByUserID: alice,
69 })
70 if err != nil {
71 t.Fatalf("create org: %v", err)
72 }
73 if string(row.Slug) != "acme" {
74 t.Fatalf("slug: want acme, got %q", row.Slug)
75 }
76 // Creator is the sole owner.
77 owner, err := orgs.IsOwner(context.Background(), deps, row.ID, alice)
78 if err != nil || !owner {
79 t.Fatalf("alice should be owner: ok=%v err=%v", owner, err)
80 }
81 }
82
83 func TestCreate_RejectsReservedSlug(t *testing.T) {
84 _, deps, alice := setup(t)
85 _, err := orgs.Create(context.Background(), deps, orgs.CreateParams{
86 Slug: "settings", CreatedByUserID: alice,
87 })
88 if !errors.Is(err, orgs.ErrSlugReserved) {
89 t.Fatalf("want ErrSlugReserved, got %v", err)
90 }
91 }
92
93 func TestCreate_RejectsCollisionWithUsername(t *testing.T) {
94 _, deps, alice := setup(t)
95 // alice exists; trying to create org with slug "alice" must fail.
96 _, err := orgs.Create(context.Background(), deps, orgs.CreateParams{
97 Slug: "alice", CreatedByUserID: alice,
98 })
99 if !errors.Is(err, orgs.ErrSlugTaken) {
100 t.Fatalf("want ErrSlugTaken, got %v", err)
101 }
102 }
103
104 func TestPrincipals_ResolveUserAndOrg(t *testing.T) {
105 pool, deps, alice := setup(t)
106 if _, err := orgs.Create(context.Background(), deps, orgs.CreateParams{
107 Slug: "beta", CreatedByUserID: alice,
108 }); err != nil {
109 t.Fatalf("create org: %v", err)
110 }
111 pUser, err := orgs.Resolve(context.Background(), pool, "alice")
112 if err != nil || pUser.Kind != orgs.PrincipalUser {
113 t.Fatalf("resolve alice: kind=%v err=%v", pUser.Kind, err)
114 }
115 pOrg, err := orgs.Resolve(context.Background(), pool, "beta")
116 if err != nil || pOrg.Kind != orgs.PrincipalOrg {
117 t.Fatalf("resolve beta: kind=%v err=%v", pOrg.Kind, err)
118 }
119 if _, err := orgs.Resolve(context.Background(), pool, "nope"); !errors.Is(err, orgs.ErrNoPrincipal) {
120 t.Fatalf("want ErrNoPrincipal for unknown slug, got %v", err)
121 }
122 }
123
124 func TestChangeRole_LastOwnerProtection(t *testing.T) {
125 _, deps, alice := setup(t)
126 row, err := orgs.Create(context.Background(), deps, orgs.CreateParams{
127 Slug: "acme", CreatedByUserID: alice,
128 })
129 if err != nil {
130 t.Fatalf("create: %v", err)
131 }
132 // Demoting the only owner must refuse.
133 err = orgs.ChangeRole(context.Background(), deps, row.ID, alice, "member")
134 if !errors.Is(err, orgs.ErrLastOwner) {
135 t.Fatalf("want ErrLastOwner, got %v", err)
136 }
137 // Removing the only owner must refuse.
138 err = orgs.RemoveMember(context.Background(), deps, row.ID, alice)
139 if !errors.Is(err, orgs.ErrLastOwner) {
140 t.Fatalf("want ErrLastOwner on remove, got %v", err)
141 }
142 }
143
144 func TestInvite_AcceptByUsername(t *testing.T) {
145 pool, deps, alice := setup(t)
146 bob := mustUser(t, pool, "bob")
147 row, err := orgs.Create(context.Background(), deps, orgs.CreateParams{
148 Slug: "acme", CreatedByUserID: alice,
149 })
150 if err != nil {
151 t.Fatalf("create: %v", err)
152 }
153 res, err := orgs.Invite(context.Background(), deps, orgs.InviteParams{
154 OrgID: row.ID, InvitedByUserID: alice,
155 TargetUsername: "bob", Role: "member",
156 })
157 if err != nil {
158 t.Fatalf("invite: %v", err)
159 }
160 if !res.Invitation.TargetUserID.Valid || res.Invitation.TargetUserID.Int64 != bob {
161 t.Fatalf("target user id mismatch")
162 }
163 if err := orgs.AcceptInvitation(context.Background(), deps, res.Invitation, bob); err != nil {
164 t.Fatalf("accept: %v", err)
165 }
166 ok, _ := orgs.IsMember(context.Background(), deps, row.ID, bob)
167 if !ok {
168 t.Fatalf("bob should be a member")
169 }
170 }
171
172 func TestInvite_AcceptByEmail(t *testing.T) {
173 pool, deps, alice := setup(t)
174 carol := mustUser(t, pool, "carol")
175 mustEmail(t, pool, carol, "carol@test.com")
176 row, err := orgs.Create(context.Background(), deps, orgs.CreateParams{
177 Slug: "acme", CreatedByUserID: alice,
178 })
179 if err != nil {
180 t.Fatalf("create: %v", err)
181 }
182 res, err := orgs.Invite(context.Background(), deps, orgs.InviteParams{
183 OrgID: row.ID, InvitedByUserID: alice,
184 TargetEmail: "carol@test.com", Role: "member",
185 })
186 if err != nil {
187 t.Fatalf("invite: %v", err)
188 }
189 if !res.Invitation.TargetEmail.Valid {
190 t.Fatalf("target email should be valid")
191 }
192 if err := orgs.AcceptInvitation(context.Background(), deps, res.Invitation, carol); err != nil {
193 t.Fatalf("accept: %v", err)
194 }
195 ok, _ := orgs.IsMember(context.Background(), deps, row.ID, carol)
196 if !ok {
197 t.Fatalf("carol should be a member")
198 }
199 }
200
201 func TestInvite_AcceptByEmailRejectsWrongUser(t *testing.T) {
202 pool, deps, alice := setup(t)
203 carol := mustUser(t, pool, "carol")
204 mustEmail(t, pool, carol, "carol@test.com")
205 dave := mustUser(t, pool, "dave")
206 mustEmail(t, pool, dave, "dave@test.com")
207 row, _ := orgs.Create(context.Background(), deps, orgs.CreateParams{
208 Slug: "acme", CreatedByUserID: alice,
209 })
210 res, err := orgs.Invite(context.Background(), deps, orgs.InviteParams{
211 OrgID: row.ID, InvitedByUserID: alice,
212 TargetEmail: "carol@test.com", Role: "member",
213 })
214 if err != nil {
215 t.Fatalf("invite: %v", err)
216 }
217 // Dave (different verified email) tries to claim carol's invite.
218 err = orgs.AcceptInvitation(context.Background(), deps, res.Invitation, dave)
219 if !errors.Is(err, orgs.ErrUnauthorizedAcceptor) {
220 t.Fatalf("want ErrUnauthorizedAcceptor, got %v", err)
221 }
222 }
223
224 func TestInvite_DuplicatePending(t *testing.T) {
225 _, deps, alice := setup(t)
226 row, _ := orgs.Create(context.Background(), deps, orgs.CreateParams{
227 Slug: "acme", CreatedByUserID: alice,
228 })
229 bob := mustUser(t, deps.Pool, "bob")
230 if _, err := orgs.Invite(context.Background(), deps, orgs.InviteParams{
231 OrgID: row.ID, InvitedByUserID: alice, TargetUsername: "bob", Role: "member",
232 }); err != nil {
233 t.Fatalf("first invite: %v", err)
234 }
235 _ = bob
236 _, err := orgs.Invite(context.Background(), deps, orgs.InviteParams{
237 OrgID: row.ID, InvitedByUserID: alice, TargetUsername: "bob", Role: "member",
238 })
239 if !errors.Is(err, orgs.ErrInvitationDuplicate) {
240 t.Fatalf("want ErrInvitationDuplicate, got %v", err)
241 }
242 }
243
244 // ensure pgtype is referenced even if a future refactor removes the
245 // only inline use.
246 var _ = pgtype.Int8{}
247
248 // TestHardDelete_DropsOrgRowAndPrincipal exercises the cascade
249 // without the worker scaffolding: soft-delete + manually expire
250 // grace via raw UPDATE + HardDelete. Asserts the orgs row is gone,
251 // the principals row is gone (slug freed), and the org-owned repo
252 // is hard-deleted via the lifecycle cascade.
253 func TestHardDelete_DropsOrgRowAndPrincipal(t *testing.T) {
254 pool, deps, alice := setup(t)
255 ctx := context.Background()
256 row, err := orgs.Create(ctx, deps, orgs.CreateParams{
257 Slug: "acme", DisplayName: "Acme", CreatedByUserID: alice,
258 })
259 if err != nil {
260 t.Fatalf("create org: %v", err)
261 }
262 // Soft-delete + force expiry by backdating deleted_at past grace.
263 if err := orgs.SoftDelete(ctx, deps, row.ID, alice); err != nil {
264 t.Fatalf("soft delete: %v", err)
265 }
266 if _, err := pool.Exec(ctx,
267 `UPDATE orgs SET deleted_at = now() - interval '15 days' WHERE id=$1`, row.ID); err != nil {
268 t.Fatalf("backdate: %v", err)
269 }
270 // Sweep should see this org as past-grace.
271 ids, err := orgs.ListPastGraceOrgIDs(ctx, deps)
272 if err != nil {
273 t.Fatalf("ListPastGraceOrgIDs: %v", err)
274 }
275 found := false
276 for _, id := range ids {
277 if id == row.ID {
278 found = true
279 }
280 }
281 if !found {
282 t.Fatalf("past-grace list missing org id %d (got %v)", row.ID, ids)
283 }
284 // Cascade. RepoFS nil is fine here — no repos under this org.
285 if err := orgs.HardDelete(ctx, orgs.HardDeleteDeps{Deps: deps}, row.ID); err != nil {
286 t.Fatalf("HardDelete: %v", err)
287 }
288 // Org row gone, slug freed in principals.
289 var stillExists bool
290 _ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM orgs WHERE id=$1)`, row.ID).Scan(&stillExists)
291 if stillExists {
292 t.Fatal("org row should be gone")
293 }
294 var slugTaken bool
295 _ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM principals WHERE slug='acme')`).Scan(&slugTaken)
296 if slugTaken {
297 t.Fatal("principals slug 'acme' should be freed")
298 }
299 }
300