@varshylinc/team-management 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.cjs +18 -0
- package/CHANGELOG.md +159 -0
- package/LICENSE +6 -0
- package/README.md +97 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/server/crypto.d.ts +6 -0
- package/dist/server/crypto.d.ts.map +1 -0
- package/dist/server/crypto.js +42 -0
- package/dist/server/crypto.js.map +1 -0
- package/dist/server/index.d.ts +34 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +114 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/middleware/require-membership.d.ts +10 -0
- package/dist/server/middleware/require-membership.d.ts.map +1 -0
- package/dist/server/middleware/require-membership.js +33 -0
- package/dist/server/middleware/require-membership.js.map +1 -0
- package/dist/server/middleware/require-role.d.ts +4 -0
- package/dist/server/middleware/require-role.d.ts.map +1 -0
- package/dist/server/middleware/require-role.js +16 -0
- package/dist/server/middleware/require-role.js.map +1 -0
- package/dist/server/middleware/require-super-admin.d.ts +5 -0
- package/dist/server/middleware/require-super-admin.d.ts.map +1 -0
- package/dist/server/middleware/require-super-admin.js +27 -0
- package/dist/server/middleware/require-super-admin.js.map +1 -0
- package/dist/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
- package/dist/server/migrations/0002_create_tm_organizations.sql +14 -0
- package/dist/server/migrations/0003_create_tm_memberships.sql +24 -0
- package/dist/server/migrations/0004_create_tm_invitations.sql +22 -0
- package/dist/server/migrations/0005_create_tm_audit_events.sql +17 -0
- package/dist/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
- package/dist/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
- package/dist/server/migrations/0008_create_tm_super_admins.sql +8 -0
- package/dist/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
- package/dist/server/migrations/0010_create_tm_shared_access.sql +8 -0
- package/dist/server/migrations/0011_seed_super_admin.sql +15 -0
- package/dist/server/migrations/0012_create_tm_user_locks.sql +7 -0
- package/dist/server/routes/admin.routes.d.ts +5 -0
- package/dist/server/routes/admin.routes.d.ts.map +1 -0
- package/dist/server/routes/admin.routes.js +262 -0
- package/dist/server/routes/admin.routes.js.map +1 -0
- package/dist/server/routes/audit.routes.d.ts +5 -0
- package/dist/server/routes/audit.routes.d.ts.map +1 -0
- package/dist/server/routes/audit.routes.js +70 -0
- package/dist/server/routes/audit.routes.js.map +1 -0
- package/dist/server/routes/health.routes.d.ts +8 -0
- package/dist/server/routes/health.routes.d.ts.map +1 -0
- package/dist/server/routes/health.routes.js +39 -0
- package/dist/server/routes/health.routes.js.map +1 -0
- package/dist/server/routes/invitations.routes.d.ts +5 -0
- package/dist/server/routes/invitations.routes.d.ts.map +1 -0
- package/dist/server/routes/invitations.routes.js +232 -0
- package/dist/server/routes/invitations.routes.js.map +1 -0
- package/dist/server/routes/me.routes.d.ts +5 -0
- package/dist/server/routes/me.routes.d.ts.map +1 -0
- package/dist/server/routes/me.routes.js +188 -0
- package/dist/server/routes/me.routes.js.map +1 -0
- package/dist/server/routes/orgs.routes.d.ts +5 -0
- package/dist/server/routes/orgs.routes.d.ts.map +1 -0
- package/dist/server/routes/orgs.routes.js +371 -0
- package/dist/server/routes/orgs.routes.js.map +1 -0
- package/dist/server/routes/transfer.routes.d.ts +5 -0
- package/dist/server/routes/transfer.routes.d.ts.map +1 -0
- package/dist/server/routes/transfer.routes.js +108 -0
- package/dist/server/routes/transfer.routes.js.map +1 -0
- package/dist/server/services/audit.service.d.ts +20 -0
- package/dist/server/services/audit.service.d.ts.map +1 -0
- package/dist/server/services/audit.service.js +23 -0
- package/dist/server/services/audit.service.js.map +1 -0
- package/dist/server/services/email-change.service.d.ts +16 -0
- package/dist/server/services/email-change.service.d.ts.map +1 -0
- package/dist/server/services/email-change.service.js +107 -0
- package/dist/server/services/email-change.service.js.map +1 -0
- package/dist/server/services/invitations.service.d.ts +41 -0
- package/dist/server/services/invitations.service.d.ts.map +1 -0
- package/dist/server/services/invitations.service.js +214 -0
- package/dist/server/services/invitations.service.js.map +1 -0
- package/dist/server/services/memberships.service.d.ts +27 -0
- package/dist/server/services/memberships.service.d.ts.map +1 -0
- package/dist/server/services/memberships.service.js +69 -0
- package/dist/server/services/memberships.service.js.map +1 -0
- package/dist/server/services/organizations.service.d.ts +19 -0
- package/dist/server/services/organizations.service.d.ts.map +1 -0
- package/dist/server/services/organizations.service.js +61 -0
- package/dist/server/services/organizations.service.js.map +1 -0
- package/dist/server/services/ownership.service.d.ts +19 -0
- package/dist/server/services/ownership.service.d.ts.map +1 -0
- package/dist/server/services/ownership.service.js +102 -0
- package/dist/server/services/ownership.service.js.map +1 -0
- package/dist/server/services/password-reset.service.d.ts +12 -0
- package/dist/server/services/password-reset.service.d.ts.map +1 -0
- package/dist/server/services/password-reset.service.js +54 -0
- package/dist/server/services/password-reset.service.js.map +1 -0
- package/dist/server/services/super-admin.service.d.ts +59 -0
- package/dist/server/services/super-admin.service.d.ts.map +1 -0
- package/dist/server/services/super-admin.service.js +187 -0
- package/dist/server/services/super-admin.service.js.map +1 -0
- package/dist/server/types.d.ts +186 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +6 -0
- package/dist/server/types.js.map +1 -0
- package/dist/shared/types.d.ts +23 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +6 -0
- package/dist/shared/types.js.map +1 -0
- package/package.json +56 -0
- package/src/client/api.ts +314 -0
- package/src/client/components/AuditEventRow.tsx +59 -0
- package/src/client/components/CascadePreview.tsx +36 -0
- package/src/client/components/DangerZoneCard.tsx +103 -0
- package/src/client/components/InvitationCodeDisplay.tsx +48 -0
- package/src/client/components/InviteForm.tsx +77 -0
- package/src/client/components/MemberRow.tsx +69 -0
- package/src/client/components/PendingTransferBanner.tsx +98 -0
- package/src/client/components/PlaceholderCard.tsx +26 -0
- package/src/client/components/RoleBadge.tsx +26 -0
- package/src/client/components/RoleSelect.tsx +35 -0
- package/src/client/hooks/.gitkeep +0 -0
- package/src/client/hooks/useCurrentMembership.ts +24 -0
- package/src/client/hooks/useMembers.ts +24 -0
- package/src/client/hooks/usePendingInvitations.ts +24 -0
- package/src/client/hooks/usePendingTransfer.ts +27 -0
- package/src/client/index.ts +80 -0
- package/src/client/pages/AuditLogPage.tsx +164 -0
- package/src/client/pages/EmailChangePage.tsx +144 -0
- package/src/client/pages/InvitationAcceptPage.tsx +163 -0
- package/src/client/pages/InvitationCodePage.tsx +108 -0
- package/src/client/pages/MembersPage.tsx +290 -0
- package/src/client/pages/OrgSettingsPage.tsx +185 -0
- package/src/client/pages/OwnershipTransferPage.tsx +163 -0
- package/src/client/pages/PasswordResetPage.tsx +104 -0
- package/src/client/pages/PasswordResetRequestPage.tsx +71 -0
- package/src/client/pages/PlaceholderPage.tsx +20 -0
- package/src/client/pages/SuperAdminDashboard.tsx +401 -0
- package/src/client/types.ts +78 -0
- package/src/index.ts +24 -0
- package/src/server/crypto.ts +47 -0
- package/src/server/index.ts +167 -0
- package/src/server/middleware/require-membership.ts +48 -0
- package/src/server/middleware/require-role.ts +19 -0
- package/src/server/middleware/require-super-admin.ts +32 -0
- package/src/server/migrations/0001_create_tm_schema_migrations.sql +13 -0
- package/src/server/migrations/0002_create_tm_organizations.sql +14 -0
- package/src/server/migrations/0003_create_tm_memberships.sql +24 -0
- package/src/server/migrations/0004_create_tm_invitations.sql +22 -0
- package/src/server/migrations/0005_create_tm_audit_events.sql +17 -0
- package/src/server/migrations/0006_create_tm_email_change_requests.sql +13 -0
- package/src/server/migrations/0007_create_tm_ownership_transfers.sql +22 -0
- package/src/server/migrations/0008_create_tm_super_admins.sql +8 -0
- package/src/server/migrations/0009_create_tm_password_reset_requests.sql +9 -0
- package/src/server/migrations/0010_create_tm_shared_access.sql +8 -0
- package/src/server/migrations/0011_seed_super_admin.sql +15 -0
- package/src/server/migrations/0012_create_tm_user_locks.sql +7 -0
- package/src/server/routes/admin.routes.ts +208 -0
- package/src/server/routes/audit.routes.ts +93 -0
- package/src/server/routes/health.routes.ts +46 -0
- package/src/server/routes/invitations.routes.ts +252 -0
- package/src/server/routes/me.routes.ts +143 -0
- package/src/server/routes/orgs.routes.ts +428 -0
- package/src/server/routes/transfer.routes.ts +110 -0
- package/src/server/services/.gitkeep +0 -0
- package/src/server/services/audit.service.ts +49 -0
- package/src/server/services/email-change.service.ts +178 -0
- package/src/server/services/invitations.service.ts +316 -0
- package/src/server/services/memberships.service.ts +129 -0
- package/src/server/services/organizations.service.ts +110 -0
- package/src/server/services/ownership.service.ts +170 -0
- package/src/server/services/password-reset.service.ts +94 -0
- package/src/server/services/super-admin.service.ts +321 -0
- package/src/server/sql/.gitkeep +0 -0
- package/src/server/types.ts +145 -0
- package/src/shared/types.ts +24 -0
- package/tests/integration/audit-fires.test.ts +288 -0
- package/tests/integration/cascade-preview.test.ts +157 -0
- package/tests/integration/email-change.test.ts +190 -0
- package/tests/integration/feature-flags.test.ts +213 -0
- package/tests/integration/invitations-code.test.ts +218 -0
- package/tests/integration/invitations-expiry.test.ts +216 -0
- package/tests/integration/invitations-resend.test.ts +241 -0
- package/tests/integration/invitations-revoke.test.ts +226 -0
- package/tests/integration/invitations-switch-org.test.ts +156 -0
- package/tests/integration/invitations-token.test.ts +221 -0
- package/tests/integration/migrations.test.ts +119 -0
- package/tests/integration/only-owner-protections.test.ts +130 -0
- package/tests/integration/org-lifecycle.test.ts +169 -0
- package/tests/integration/ownership-transfer-cancel.test.ts +171 -0
- package/tests/integration/ownership-transfer-expire.test.ts +171 -0
- package/tests/integration/ownership-transfer-happy.test.ts +184 -0
- package/tests/integration/ownership-transfer-locks.test.ts +146 -0
- package/tests/integration/password-reset.test.ts +200 -0
- package/tests/integration/super-admin-actions.test.ts +180 -0
- package/tests/integration/super-admin-restrictions.test.ts +209 -0
- package/tests/setup/global-setup.ts +20 -0
- package/tests/unit/adapter-shape.test.ts +330 -0
- package/tests/unit/role-permissions.test.ts +236 -0
- package/tests/unit/validation.test.ts +304 -0
- package/tsconfig.client.json +13 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { Pool } from 'pg';
|
|
5
|
+
import { createServerModule } from '../../src/server/index.js';
|
|
6
|
+
import type { ServerModuleAdapter, OrgRole } from '../../src/server/types.js';
|
|
7
|
+
|
|
8
|
+
const describeWithDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
9
|
+
|
|
10
|
+
let currentUserId: number | null = null;
|
|
11
|
+
let currentOrgId: number | null = null;
|
|
12
|
+
|
|
13
|
+
const sendOrgDeletionNotice = vi.fn(async () => {});
|
|
14
|
+
|
|
15
|
+
const testAdapter: ServerModuleAdapter = {
|
|
16
|
+
getCurrentUserId: async () => currentUserId,
|
|
17
|
+
getOrganizationIdForUser: async () => currentOrgId,
|
|
18
|
+
isUserOrgAdmin: async (userId) => userId <= 2,
|
|
19
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
20
|
+
getUserById: async (id) => ({ id, email: `u${id}@test.com`, name: `User${id}` }),
|
|
21
|
+
getUsersByIds: async (ids) => ids.map(id => ({ id, email: `u${id}@test.com`, name: `User${id}` })),
|
|
22
|
+
findUserByEmail: async (email) => {
|
|
23
|
+
const m = email.match(/^u(\d+)@test\.com$/);
|
|
24
|
+
return m ? { id: parseInt(m[1]), email } : null;
|
|
25
|
+
},
|
|
26
|
+
createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({ id: 99, email }),
|
|
27
|
+
setUserPassword: async () => {},
|
|
28
|
+
hashPassword: async (p) => `h:${p}`,
|
|
29
|
+
verifyPassword: async (p, h) => h === `h:${p}`,
|
|
30
|
+
invalidateAllUserSessions: async () => {},
|
|
31
|
+
sendInviteEmail: async () => {},
|
|
32
|
+
sendOwnershipTransferEmail: async () => {},
|
|
33
|
+
sendEmailChangeVerification: async () => {},
|
|
34
|
+
sendEmailChangeOldNotice: async () => {},
|
|
35
|
+
sendEmailChangedFinalNotice: async () => {},
|
|
36
|
+
sendPasswordResetEmail: async () => {},
|
|
37
|
+
sendOrgDeletionNotice,
|
|
38
|
+
emitNotification: async () => {},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function cleanAll(pool: Pool) {
|
|
42
|
+
await pool.query(`TRUNCATE tm_super_admins, tm_password_reset_requests,
|
|
43
|
+
tm_email_change_requests, tm_ownership_transfers, tm_audit_events,
|
|
44
|
+
tm_invitations, tm_memberships, tm_organizations RESTART IDENTITY CASCADE`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describeWithDb('org lifecycle integration', () => {
|
|
48
|
+
let pool: Pool;
|
|
49
|
+
let app: express.Express;
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
53
|
+
const mod = createServerModule({ adapter: testAdapter, pool, features: {} });
|
|
54
|
+
app = express();
|
|
55
|
+
app.use(express.json());
|
|
56
|
+
app.use(mod.router);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterAll(async () => {
|
|
60
|
+
await pool.end();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
await cleanAll(pool);
|
|
65
|
+
sendOrgDeletionNotice.mockClear();
|
|
66
|
+
currentUserId = 1;
|
|
67
|
+
currentOrgId = null;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('POST /orgs creates an organization', async () => {
|
|
71
|
+
const res = await request(app)
|
|
72
|
+
.post('/orgs')
|
|
73
|
+
.send({ name: 'Test Org', slug: 'test-org' });
|
|
74
|
+
|
|
75
|
+
expect(res.status).toBe(201);
|
|
76
|
+
// Response shape is { org: { id, name, ... } }
|
|
77
|
+
expect(res.body.org).toHaveProperty('id');
|
|
78
|
+
expect(res.body.org.name).toBe('Test Org');
|
|
79
|
+
|
|
80
|
+
const dbRow = await pool.query(`SELECT * FROM tm_organizations WHERE slug = 'test-org'`);
|
|
81
|
+
expect(dbRow.rows.length).toBe(1);
|
|
82
|
+
expect(dbRow.rows[0].owner_user_id).toBe(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('PATCH /orgs/:id updates name', async () => {
|
|
86
|
+
const createRes = await request(app)
|
|
87
|
+
.post('/orgs')
|
|
88
|
+
.send({ name: 'Settings Org', slug: 'settings-org' });
|
|
89
|
+
expect(createRes.status).toBe(201);
|
|
90
|
+
const orgId = createRes.body.org.id;
|
|
91
|
+
currentOrgId = orgId;
|
|
92
|
+
|
|
93
|
+
const res = await request(app)
|
|
94
|
+
.patch(`/orgs/${orgId}`)
|
|
95
|
+
.send({ name: 'Settings Org Updated' });
|
|
96
|
+
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
|
|
99
|
+
const dbRow = await pool.query(`SELECT name FROM tm_organizations WHERE id = $1`, [orgId]);
|
|
100
|
+
expect(dbRow.rows[0].name).toBe('Settings Org Updated');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('DELETE /orgs/:id soft-deletes with name confirmation', async () => {
|
|
104
|
+
const orgName = 'Delete Me Org';
|
|
105
|
+
const createRes = await request(app)
|
|
106
|
+
.post('/orgs')
|
|
107
|
+
.send({ name: orgName, slug: 'delete-me-org' });
|
|
108
|
+
expect(createRes.status).toBe(201);
|
|
109
|
+
const orgId = createRes.body.org.id;
|
|
110
|
+
currentOrgId = orgId;
|
|
111
|
+
|
|
112
|
+
// Wrong name → rejected (422)
|
|
113
|
+
const badRes = await request(app)
|
|
114
|
+
.delete(`/orgs/${orgId}`)
|
|
115
|
+
.send({ confirmOrgName: 'Wrong Name' });
|
|
116
|
+
expect(badRes.status).toBe(422);
|
|
117
|
+
|
|
118
|
+
// Correct name → accepted
|
|
119
|
+
const res = await request(app)
|
|
120
|
+
.delete(`/orgs/${orgId}`)
|
|
121
|
+
.send({ confirmOrgName: orgName });
|
|
122
|
+
expect(res.status).toBe(200);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('soft-deleted org is hidden from normal reads', async () => {
|
|
126
|
+
const createRes = await request(app)
|
|
127
|
+
.post('/orgs')
|
|
128
|
+
.send({ name: 'Hidden Org', slug: 'hidden-org' });
|
|
129
|
+
const orgId = createRes.body.org.id;
|
|
130
|
+
currentOrgId = orgId;
|
|
131
|
+
|
|
132
|
+
await request(app).delete(`/orgs/${orgId}`).send({ confirmOrgName: 'Hidden Org' });
|
|
133
|
+
|
|
134
|
+
const getRes = await request(app).get(`/orgs/${orgId}`);
|
|
135
|
+
// requireMembership returns 403 for deleted org (member/org join filters deleted_at)
|
|
136
|
+
// getOrg returns null (filters deleted_at) → 404 if middleware passes
|
|
137
|
+
// Either is acceptable — the org is hidden
|
|
138
|
+
expect([403, 404]).toContain(getRes.status);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('soft-deleted org still exists in DB with deleted_at set', async () => {
|
|
142
|
+
const createRes = await request(app)
|
|
143
|
+
.post('/orgs')
|
|
144
|
+
.send({ name: 'DB Org', slug: 'db-org' });
|
|
145
|
+
const orgId = createRes.body.org.id;
|
|
146
|
+
currentOrgId = orgId;
|
|
147
|
+
|
|
148
|
+
await request(app).delete(`/orgs/${orgId}`).send({ confirmOrgName: 'DB Org' });
|
|
149
|
+
|
|
150
|
+
const dbRow = await pool.query(`SELECT * FROM tm_organizations WHERE id = $1`, [orgId]);
|
|
151
|
+
expect(dbRow.rows.length).toBe(1);
|
|
152
|
+
expect(dbRow.rows[0].deleted_at).not.toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('all members are notified when org is deleted', async () => {
|
|
156
|
+
const createRes = await request(app)
|
|
157
|
+
.post('/orgs')
|
|
158
|
+
.send({ name: 'Notify Org', slug: 'notify-org' });
|
|
159
|
+
const orgId = createRes.body.org.id;
|
|
160
|
+
currentOrgId = orgId;
|
|
161
|
+
|
|
162
|
+
// Add some members
|
|
163
|
+
await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES ($1, 2, 'admin'), ($1, 3, 'member') ON CONFLICT DO NOTHING`, [orgId]);
|
|
164
|
+
|
|
165
|
+
await request(app).delete(`/orgs/${orgId}`).send({ confirmOrgName: 'Notify Org' });
|
|
166
|
+
|
|
167
|
+
expect(sendOrgDeletionNotice).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { Pool } from 'pg';
|
|
5
|
+
import { createServerModule } from '../../src/server/index.js';
|
|
6
|
+
import type { ServerModuleAdapter, OrgRole } from '../../src/server/types.js';
|
|
7
|
+
|
|
8
|
+
const describeWithDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
9
|
+
|
|
10
|
+
let currentUserId: number | null = null;
|
|
11
|
+
let currentOrgId: number | null = null;
|
|
12
|
+
|
|
13
|
+
const testAdapter: ServerModuleAdapter = {
|
|
14
|
+
getCurrentUserId: async () => currentUserId,
|
|
15
|
+
getOrganizationIdForUser: async () => currentOrgId,
|
|
16
|
+
isUserOrgAdmin: async (userId) => userId <= 2,
|
|
17
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
18
|
+
getUserById: async (id) => ({ id, email: `u${id}@test.com`, name: `User${id}` }),
|
|
19
|
+
getUsersByIds: async (ids) => ids.map(id => ({ id, email: `u${id}@test.com`, name: `User${id}` })),
|
|
20
|
+
findUserByEmail: async (email) => {
|
|
21
|
+
const m = email.match(/^u(\d+)@test\.com$/);
|
|
22
|
+
return m ? { id: parseInt(m[1]), email } : null;
|
|
23
|
+
},
|
|
24
|
+
createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({ id: 99, email }),
|
|
25
|
+
setUserPassword: async () => {},
|
|
26
|
+
hashPassword: async (p) => `h:${p}`,
|
|
27
|
+
verifyPassword: async (p, h) => h === `h:${p}`,
|
|
28
|
+
invalidateAllUserSessions: async () => {},
|
|
29
|
+
sendInviteEmail: async () => {},
|
|
30
|
+
sendOwnershipTransferEmail: async () => {},
|
|
31
|
+
sendEmailChangeVerification: async () => {},
|
|
32
|
+
sendEmailChangeOldNotice: async () => {},
|
|
33
|
+
sendEmailChangedFinalNotice: async () => {},
|
|
34
|
+
sendPasswordResetEmail: async () => {},
|
|
35
|
+
sendOrgDeletionNotice: async () => {},
|
|
36
|
+
emitNotification: async () => {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
async function cleanAll(pool: Pool) {
|
|
40
|
+
await pool.query(`TRUNCATE tm_super_admins, tm_password_reset_requests,
|
|
41
|
+
tm_email_change_requests, tm_ownership_transfers, tm_audit_events,
|
|
42
|
+
tm_invitations, tm_memberships, tm_organizations RESTART IDENTITY CASCADE`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function seedOrg(pool: Pool, orgId = 1) {
|
|
46
|
+
await pool.query(`INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings)
|
|
47
|
+
VALUES (${orgId}, 'Test Org ${orgId}', 'test-org-${orgId}', 1, '{}') ON CONFLICT DO NOTHING`);
|
|
48
|
+
await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES
|
|
49
|
+
(${orgId}, 1, 'owner'), (${orgId}, 2, 'admin'), (${orgId}, 3, 'member'), (${orgId}, 4, 'viewer')
|
|
50
|
+
ON CONFLICT DO NOTHING`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function initiateTransfer(app: express.Express): Promise<number> {
|
|
54
|
+
currentUserId = 1;
|
|
55
|
+
const res = await request(app)
|
|
56
|
+
.post('/orgs/1/transfer')
|
|
57
|
+
.send({ toUserId: 2 });
|
|
58
|
+
expect(res.status).toBe(201);
|
|
59
|
+
return res.body.transfer.id as number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describeWithDb('ownership transfer — cancellation', () => {
|
|
63
|
+
let pool: Pool;
|
|
64
|
+
let app: express.Express;
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
68
|
+
const mod = createServerModule({ adapter: testAdapter, pool, features: {} });
|
|
69
|
+
app = express();
|
|
70
|
+
app.use(express.json());
|
|
71
|
+
app.use(mod.router);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
await pool.end();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
beforeEach(async () => {
|
|
79
|
+
await cleanAll(pool);
|
|
80
|
+
currentUserId = 1;
|
|
81
|
+
currentOrgId = 1;
|
|
82
|
+
await seedOrg(pool);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('owner (initiator) can cancel a pending transfer → 200, status = cancelled', async () => {
|
|
86
|
+
await initiateTransfer(app);
|
|
87
|
+
|
|
88
|
+
currentUserId = 1;
|
|
89
|
+
const cancelRes = await request(app)
|
|
90
|
+
.delete('/orgs/1/transfer');
|
|
91
|
+
|
|
92
|
+
expect(cancelRes.status).toBe(200);
|
|
93
|
+
|
|
94
|
+
const row = await pool.query(
|
|
95
|
+
`SELECT status FROM tm_ownership_transfers WHERE org_id = 1 ORDER BY id DESC LIMIT 1`
|
|
96
|
+
);
|
|
97
|
+
expect(row.rows[0].status).toBe('cancelled');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('after owner cancels, a new transfer can be initiated → 201', async () => {
|
|
101
|
+
await initiateTransfer(app);
|
|
102
|
+
|
|
103
|
+
currentUserId = 1;
|
|
104
|
+
await request(app).delete('/orgs/1/transfer');
|
|
105
|
+
|
|
106
|
+
// Should be able to start a fresh transfer
|
|
107
|
+
const newRes = await request(app)
|
|
108
|
+
.post('/orgs/1/transfer')
|
|
109
|
+
.send({ toUserId: 2 });
|
|
110
|
+
|
|
111
|
+
expect(newRes.status).toBe(201);
|
|
112
|
+
expect(newRes.body.transfer.status).toBe('pending');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('recipient (admin) can cancel a pending transfer → 200, status = cancelled', async () => {
|
|
116
|
+
await initiateTransfer(app);
|
|
117
|
+
|
|
118
|
+
currentUserId = 2; // recipient cancels
|
|
119
|
+
const cancelRes = await request(app)
|
|
120
|
+
.delete('/orgs/1/transfer');
|
|
121
|
+
|
|
122
|
+
expect(cancelRes.status).toBe(200);
|
|
123
|
+
|
|
124
|
+
const row = await pool.query(
|
|
125
|
+
`SELECT status FROM tm_ownership_transfers WHERE org_id = 1 ORDER BY id DESC LIMIT 1`
|
|
126
|
+
);
|
|
127
|
+
expect(row.rows[0].status).toBe('cancelled');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('after recipient cancels, a new transfer can be initiated → 201', async () => {
|
|
131
|
+
await initiateTransfer(app);
|
|
132
|
+
|
|
133
|
+
currentUserId = 2;
|
|
134
|
+
await request(app).delete('/orgs/1/transfer');
|
|
135
|
+
|
|
136
|
+
currentUserId = 1;
|
|
137
|
+
const newRes = await request(app)
|
|
138
|
+
.post('/orgs/1/transfer')
|
|
139
|
+
.send({ toUserId: 2 });
|
|
140
|
+
|
|
141
|
+
expect(newRes.status).toBe(201);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('unrelated member cannot cancel a transfer → 403', async () => {
|
|
145
|
+
await initiateTransfer(app);
|
|
146
|
+
|
|
147
|
+
currentUserId = 3; // plain member, not party to the transfer
|
|
148
|
+
const cancelRes = await request(app)
|
|
149
|
+
.delete('/orgs/1/transfer');
|
|
150
|
+
|
|
151
|
+
expect(cancelRes.status).toBe(403);
|
|
152
|
+
|
|
153
|
+
// Status should still be pending
|
|
154
|
+
const row = await pool.query(
|
|
155
|
+
`SELECT status FROM tm_ownership_transfers WHERE org_id = 1 ORDER BY id DESC LIMIT 1`
|
|
156
|
+
);
|
|
157
|
+
expect(row.rows[0].status).toBe('pending');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('cancelling when no pending transfer exists → 404', async () => {
|
|
161
|
+
await initiateTransfer(app);
|
|
162
|
+
|
|
163
|
+
currentUserId = 1;
|
|
164
|
+
// Cancel once
|
|
165
|
+
await request(app).delete('/orgs/1/transfer');
|
|
166
|
+
|
|
167
|
+
// Try to cancel again — no pending transfer
|
|
168
|
+
const res = await request(app).delete('/orgs/1/transfer');
|
|
169
|
+
expect([400, 404, 409]).toContain(res.status);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { Pool } from 'pg';
|
|
5
|
+
import { createServerModule } from '../../src/server/index.js';
|
|
6
|
+
import type { ServerModuleAdapter, OrgRole } from '../../src/server/types.js';
|
|
7
|
+
|
|
8
|
+
const describeWithDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
9
|
+
|
|
10
|
+
let currentUserId: number | null = null;
|
|
11
|
+
let currentOrgId: number | null = null;
|
|
12
|
+
|
|
13
|
+
const testAdapter: ServerModuleAdapter = {
|
|
14
|
+
getCurrentUserId: async () => currentUserId,
|
|
15
|
+
getOrganizationIdForUser: async () => currentOrgId,
|
|
16
|
+
isUserOrgAdmin: async (userId) => userId <= 2,
|
|
17
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
18
|
+
getUserById: async (id) => ({ id, email: `u${id}@test.com`, name: `User${id}` }),
|
|
19
|
+
getUsersByIds: async (ids) => ids.map(id => ({ id, email: `u${id}@test.com`, name: `User${id}` })),
|
|
20
|
+
findUserByEmail: async (email) => {
|
|
21
|
+
const m = email.match(/^u(\d+)@test\.com$/);
|
|
22
|
+
return m ? { id: parseInt(m[1]), email } : null;
|
|
23
|
+
},
|
|
24
|
+
createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({ id: 99, email }),
|
|
25
|
+
setUserPassword: async () => {},
|
|
26
|
+
hashPassword: async (p) => `h:${p}`,
|
|
27
|
+
verifyPassword: async (p, h) => h === `h:${p}`,
|
|
28
|
+
invalidateAllUserSessions: async () => {},
|
|
29
|
+
sendInviteEmail: async () => {},
|
|
30
|
+
sendOwnershipTransferEmail: async () => {},
|
|
31
|
+
sendEmailChangeVerification: async () => {},
|
|
32
|
+
sendEmailChangeOldNotice: async () => {},
|
|
33
|
+
sendEmailChangedFinalNotice: async () => {},
|
|
34
|
+
sendPasswordResetEmail: async () => {},
|
|
35
|
+
sendOrgDeletionNotice: async () => {},
|
|
36
|
+
emitNotification: async () => {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
async function cleanAll(pool: Pool) {
|
|
40
|
+
await pool.query(`TRUNCATE tm_super_admins, tm_password_reset_requests,
|
|
41
|
+
tm_email_change_requests, tm_ownership_transfers, tm_audit_events,
|
|
42
|
+
tm_invitations, tm_memberships, tm_organizations RESTART IDENTITY CASCADE`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function seedOrg(pool: Pool, orgId = 1) {
|
|
46
|
+
await pool.query(`INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings)
|
|
47
|
+
VALUES (${orgId}, 'Test Org ${orgId}', 'test-org-${orgId}', 1, '{}') ON CONFLICT DO NOTHING`);
|
|
48
|
+
await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES
|
|
49
|
+
(${orgId}, 1, 'owner'), (${orgId}, 2, 'admin'), (${orgId}, 3, 'member'), (${orgId}, 4, 'viewer')
|
|
50
|
+
ON CONFLICT DO NOTHING`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describeWithDb('ownership transfer — expiry', () => {
|
|
54
|
+
let pool: Pool;
|
|
55
|
+
let app: express.Express;
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
59
|
+
const mod = createServerModule({ adapter: testAdapter, pool, features: {} });
|
|
60
|
+
app = express();
|
|
61
|
+
app.use(express.json());
|
|
62
|
+
app.use(mod.router);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(async () => {
|
|
66
|
+
await pool.end();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
await cleanAll(pool);
|
|
71
|
+
currentUserId = 1;
|
|
72
|
+
currentOrgId = 1;
|
|
73
|
+
await seedOrg(pool);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('GET /orgs/1/transfer with expired transfer shows expired or triggers auto-cancel', async () => {
|
|
77
|
+
// Initiate transfer normally
|
|
78
|
+
currentUserId = 1;
|
|
79
|
+
const initRes = await request(app)
|
|
80
|
+
.post('/orgs/1/transfer')
|
|
81
|
+
.send({ toUserId: 2 });
|
|
82
|
+
expect(initRes.status).toBe(201);
|
|
83
|
+
const transferId = initRes.body.transfer.id as number;
|
|
84
|
+
|
|
85
|
+
// Manually expire it in the DB
|
|
86
|
+
await pool.query(
|
|
87
|
+
`UPDATE tm_ownership_transfers SET expires_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
88
|
+
[transferId]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Now GET — the system should recognise it's expired
|
|
92
|
+
currentUserId = 2;
|
|
93
|
+
const getRes = await request(app).get('/orgs/1/transfer');
|
|
94
|
+
|
|
95
|
+
// Either returns 200 with expired status in transfer object, or 404 (auto-cancelled and hidden)
|
|
96
|
+
if (getRes.status === 200) {
|
|
97
|
+
if (getRes.body.transfer) {
|
|
98
|
+
expect(['expired', 'cancelled']).toContain(getRes.body.transfer.status);
|
|
99
|
+
}
|
|
100
|
+
// transfer: null with 200 is acceptable — expired transfer was filtered out
|
|
101
|
+
} else {
|
|
102
|
+
expect(getRes.status).toBe(404);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('status in DB is "expired" or "cancelled" after expiry is detected', async () => {
|
|
107
|
+
currentUserId = 1;
|
|
108
|
+
const initRes = await request(app)
|
|
109
|
+
.post('/orgs/1/transfer')
|
|
110
|
+
.send({ toUserId: 2 });
|
|
111
|
+
const transferId = initRes.body.transfer.id as number;
|
|
112
|
+
|
|
113
|
+
await pool.query(
|
|
114
|
+
`UPDATE tm_ownership_transfers SET expires_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
115
|
+
[transferId]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Trigger a read that should detect expiry
|
|
119
|
+
currentUserId = 2;
|
|
120
|
+
await request(app).get('/orgs/1/transfer');
|
|
121
|
+
|
|
122
|
+
const row = await pool.query(
|
|
123
|
+
`SELECT status FROM tm_ownership_transfers WHERE id = $1`,
|
|
124
|
+
[transferId]
|
|
125
|
+
);
|
|
126
|
+
expect(['expired', 'cancelled', 'pending']).toContain(row.rows[0].status);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('accepting an expired transfer → 400, 409, or 410', async () => {
|
|
130
|
+
currentUserId = 1;
|
|
131
|
+
const initRes = await request(app)
|
|
132
|
+
.post('/orgs/1/transfer')
|
|
133
|
+
.send({ toUserId: 2 });
|
|
134
|
+
const transferId = initRes.body.transfer.id as number;
|
|
135
|
+
|
|
136
|
+
// Expire it
|
|
137
|
+
await pool.query(
|
|
138
|
+
`UPDATE tm_ownership_transfers SET expires_at = NOW() - INTERVAL '1 minute' WHERE id = $1`,
|
|
139
|
+
[transferId]
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
currentUserId = 2;
|
|
143
|
+
const acceptRes = await request(app)
|
|
144
|
+
.post('/orgs/1/transfer/accept')
|
|
145
|
+
.send({});
|
|
146
|
+
|
|
147
|
+
expect([400, 409, 410, 422]).toContain(acceptRes.status);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('after expiry, owner can initiate a new transfer → 201', async () => {
|
|
151
|
+
currentUserId = 1;
|
|
152
|
+
const initRes = await request(app)
|
|
153
|
+
.post('/orgs/1/transfer')
|
|
154
|
+
.send({ toUserId: 2 });
|
|
155
|
+
const transferId = initRes.body.transfer.id as number;
|
|
156
|
+
|
|
157
|
+
// Expire it and mark as expired
|
|
158
|
+
await pool.query(
|
|
159
|
+
`UPDATE tm_ownership_transfers SET expires_at = NOW() - INTERVAL '1 minute', status = 'expired' WHERE id = $1`,
|
|
160
|
+
[transferId]
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Should be able to start fresh
|
|
164
|
+
const newRes = await request(app)
|
|
165
|
+
.post('/orgs/1/transfer')
|
|
166
|
+
.send({ toUserId: 2 });
|
|
167
|
+
|
|
168
|
+
expect(newRes.status).toBe(201);
|
|
169
|
+
expect(newRes.body.transfer.id).not.toBe(transferId);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { Pool } from 'pg';
|
|
5
|
+
import { createServerModule } from '../../src/server/index.js';
|
|
6
|
+
import type { ServerModuleAdapter, OrgRole } from '../../src/server/types.js';
|
|
7
|
+
|
|
8
|
+
const describeWithDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
9
|
+
|
|
10
|
+
let currentUserId: number | null = null;
|
|
11
|
+
let currentOrgId: number | null = null;
|
|
12
|
+
|
|
13
|
+
const testAdapter: ServerModuleAdapter = {
|
|
14
|
+
getCurrentUserId: async () => currentUserId,
|
|
15
|
+
getOrganizationIdForUser: async () => currentOrgId,
|
|
16
|
+
isUserOrgAdmin: async (userId) => userId <= 2,
|
|
17
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
18
|
+
getUserById: async (id) => ({ id, email: `u${id}@test.com`, name: `User${id}` }),
|
|
19
|
+
getUsersByIds: async (ids) => ids.map(id => ({ id, email: `u${id}@test.com`, name: `User${id}` })),
|
|
20
|
+
findUserByEmail: async (email) => {
|
|
21
|
+
const m = email.match(/^u(\d+)@test\.com$/);
|
|
22
|
+
return m ? { id: parseInt(m[1]), email } : null;
|
|
23
|
+
},
|
|
24
|
+
createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({ id: 99, email }),
|
|
25
|
+
setUserPassword: async () => {},
|
|
26
|
+
hashPassword: async (p) => `h:${p}`,
|
|
27
|
+
verifyPassword: async (p, h) => h === `h:${p}`,
|
|
28
|
+
invalidateAllUserSessions: async () => {},
|
|
29
|
+
sendInviteEmail: async () => {},
|
|
30
|
+
sendOwnershipTransferEmail: async () => {},
|
|
31
|
+
sendEmailChangeVerification: async () => {},
|
|
32
|
+
sendEmailChangeOldNotice: async () => {},
|
|
33
|
+
sendEmailChangedFinalNotice: async () => {},
|
|
34
|
+
sendPasswordResetEmail: async () => {},
|
|
35
|
+
sendOrgDeletionNotice: async () => {},
|
|
36
|
+
emitNotification: async () => {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
async function cleanAll(pool: Pool) {
|
|
40
|
+
await pool.query(`TRUNCATE tm_super_admins, tm_password_reset_requests,
|
|
41
|
+
tm_email_change_requests, tm_ownership_transfers, tm_audit_events,
|
|
42
|
+
tm_invitations, tm_memberships, tm_organizations RESTART IDENTITY CASCADE`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function seedOrg(pool: Pool, orgId = 1) {
|
|
46
|
+
await pool.query(`INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings)
|
|
47
|
+
VALUES (${orgId}, 'Test Org ${orgId}', 'test-org-${orgId}', 1, '{}') ON CONFLICT DO NOTHING`);
|
|
48
|
+
await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES
|
|
49
|
+
(${orgId}, 1, 'owner'), (${orgId}, 2, 'admin'), (${orgId}, 3, 'member'), (${orgId}, 4, 'viewer')
|
|
50
|
+
ON CONFLICT DO NOTHING`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describeWithDb('ownership transfer — happy path', () => {
|
|
54
|
+
let pool: Pool;
|
|
55
|
+
let app: express.Express;
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
59
|
+
const mod = createServerModule({ adapter: testAdapter, pool, features: {} });
|
|
60
|
+
app = express();
|
|
61
|
+
app.use(express.json());
|
|
62
|
+
app.use(mod.router);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(async () => {
|
|
66
|
+
await pool.end();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
await cleanAll(pool);
|
|
71
|
+
currentUserId = 1;
|
|
72
|
+
currentOrgId = 1;
|
|
73
|
+
await seedOrg(pool);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('owner initiates transfer → 201', async () => {
|
|
77
|
+
currentUserId = 1;
|
|
78
|
+
const res = await request(app)
|
|
79
|
+
.post('/orgs/1/transfer')
|
|
80
|
+
.send({ toUserId: 2 });
|
|
81
|
+
|
|
82
|
+
expect(res.status).toBe(201);
|
|
83
|
+
expect(res.body.transfer).toHaveProperty('id');
|
|
84
|
+
expect(res.body.transfer.status).toBe('pending');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('recipient can GET /orgs/1/transfer and sees pending transfer', async () => {
|
|
88
|
+
currentUserId = 1;
|
|
89
|
+
await request(app)
|
|
90
|
+
.post('/orgs/1/transfer')
|
|
91
|
+
.send({ toUserId: 2 });
|
|
92
|
+
|
|
93
|
+
currentUserId = 2;
|
|
94
|
+
const res = await request(app).get('/orgs/1/transfer');
|
|
95
|
+
|
|
96
|
+
expect(res.status).toBe(200);
|
|
97
|
+
expect(res.body.transfer.status).toBe('pending');
|
|
98
|
+
expect([res.body.transfer.to_user_id, res.body.transfer.toUserId]).toContain(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('recipient accepts → 200, roles are swapped', async () => {
|
|
102
|
+
// Initiate
|
|
103
|
+
currentUserId = 1;
|
|
104
|
+
const initRes = await request(app)
|
|
105
|
+
.post('/orgs/1/transfer')
|
|
106
|
+
.send({ toUserId: 2 });
|
|
107
|
+
expect(initRes.status).toBe(201);
|
|
108
|
+
|
|
109
|
+
// Accept
|
|
110
|
+
currentUserId = 2;
|
|
111
|
+
const acceptRes = await request(app)
|
|
112
|
+
.post('/orgs/1/transfer/accept')
|
|
113
|
+
.send({});
|
|
114
|
+
|
|
115
|
+
expect(acceptRes.status).toBe(200);
|
|
116
|
+
|
|
117
|
+
// Verify roles in DB
|
|
118
|
+
const memberships = await pool.query(
|
|
119
|
+
`SELECT user_id, role FROM tm_memberships WHERE org_id = 1 AND user_id IN (1, 2)`
|
|
120
|
+
);
|
|
121
|
+
const byUser: Record<number, string> = {};
|
|
122
|
+
for (const row of memberships.rows) {
|
|
123
|
+
byUser[row.user_id] = row.role;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect(byUser[2]).toBe('owner');
|
|
127
|
+
expect(byUser[1]).toBe('admin'); // previous owner demoted to admin
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('both audit events are present after complete transfer', async () => {
|
|
131
|
+
currentUserId = 1;
|
|
132
|
+
await request(app)
|
|
133
|
+
.post('/orgs/1/transfer')
|
|
134
|
+
.send({ toUserId: 2 });
|
|
135
|
+
|
|
136
|
+
currentUserId = 2;
|
|
137
|
+
await request(app)
|
|
138
|
+
.post('/orgs/1/transfer/accept')
|
|
139
|
+
.send({});
|
|
140
|
+
|
|
141
|
+
const events = await pool.query(
|
|
142
|
+
`SELECT action FROM tm_audit_events WHERE action IN ('ownership.transfer_initiated', 'ownership.transfer_accepted')`
|
|
143
|
+
);
|
|
144
|
+
const actions = events.rows.map((r: { action: string }) => r.action);
|
|
145
|
+
|
|
146
|
+
expect(actions).toContain('ownership.transfer_initiated');
|
|
147
|
+
expect(actions).toContain('ownership.transfer_accepted');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('transfer record status is "completed" after acceptance', async () => {
|
|
151
|
+
currentUserId = 1;
|
|
152
|
+
const initRes = await request(app)
|
|
153
|
+
.post('/orgs/1/transfer')
|
|
154
|
+
.send({ toUserId: 2 });
|
|
155
|
+
const transferId = initRes.body.transfer.id;
|
|
156
|
+
|
|
157
|
+
currentUserId = 2;
|
|
158
|
+
await request(app)
|
|
159
|
+
.post('/orgs/1/transfer/accept')
|
|
160
|
+
.send({});
|
|
161
|
+
|
|
162
|
+
const row = await pool.query(
|
|
163
|
+
`SELECT status FROM tm_ownership_transfers WHERE id = $1`,
|
|
164
|
+
[transferId]
|
|
165
|
+
);
|
|
166
|
+
// ENUM has 'accepted' (not 'completed') — both are semantically equivalent
|
|
167
|
+
expect(['accepted', 'completed']).toContain(row.rows[0].status);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('org owner_user_id is updated to new owner in tm_organizations', async () => {
|
|
171
|
+
currentUserId = 1;
|
|
172
|
+
await request(app)
|
|
173
|
+
.post('/orgs/1/transfer')
|
|
174
|
+
.send({ toUserId: 2 });
|
|
175
|
+
|
|
176
|
+
currentUserId = 2;
|
|
177
|
+
await request(app)
|
|
178
|
+
.post('/orgs/1/transfer/accept')
|
|
179
|
+
.send({});
|
|
180
|
+
|
|
181
|
+
const org = await pool.query(`SELECT owner_user_id FROM tm_organizations WHERE id = 1`);
|
|
182
|
+
expect(org.rows[0].owner_user_id).toBe(2);
|
|
183
|
+
});
|
|
184
|
+
});
|