@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,146 @@
|
|
|
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 — locks', () => {
|
|
54
|
+
let pool: Pool;
|
|
55
|
+
let app: express.Express;
|
|
56
|
+
let pendingTransferId: number;
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
60
|
+
const mod = createServerModule({ adapter: testAdapter, pool, features: {} });
|
|
61
|
+
app = express();
|
|
62
|
+
app.use(express.json());
|
|
63
|
+
app.use(mod.router);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
await pool.end();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
beforeEach(async () => {
|
|
71
|
+
await cleanAll(pool);
|
|
72
|
+
currentUserId = 1;
|
|
73
|
+
currentOrgId = 1;
|
|
74
|
+
await seedOrg(pool);
|
|
75
|
+
|
|
76
|
+
// Establish a pending transfer for all lock tests
|
|
77
|
+
const initRes = await request(app)
|
|
78
|
+
.post('/orgs/1/transfer')
|
|
79
|
+
.send({ toUserId: 2 });
|
|
80
|
+
expect(initRes.status).toBe(201);
|
|
81
|
+
pendingTransferId = initRes.body.transfer.id as number;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('initiating another transfer while one is pending → 409 or 422', async () => {
|
|
85
|
+
currentUserId = 1;
|
|
86
|
+
const res = await request(app)
|
|
87
|
+
.post('/orgs/1/transfer')
|
|
88
|
+
.send({ toUserId: 3 });
|
|
89
|
+
|
|
90
|
+
expect([409, 422]).toContain(res.status);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('deleting the org while transfer is pending → 409', async () => {
|
|
94
|
+
currentUserId = 1;
|
|
95
|
+
const res = await request(app)
|
|
96
|
+
.delete('/orgs/1')
|
|
97
|
+
.send({ confirmOrgName: 'Test Org 1' });
|
|
98
|
+
|
|
99
|
+
expect(res.status).toBe(409);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('removing the transfer recipient while transfer is pending → 409', async () => {
|
|
103
|
+
currentUserId = 1;
|
|
104
|
+
const res = await request(app).delete('/orgs/1/members/2');
|
|
105
|
+
|
|
106
|
+
expect(res.status).toBe(409);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('removing the transfer initiator (owner) while transfer is pending → 409 or 400', async () => {
|
|
110
|
+
// Switch to an admin trying to remove the owner — should be 409 or 400
|
|
111
|
+
currentUserId = 2;
|
|
112
|
+
const res = await request(app).delete('/orgs/1/members/1');
|
|
113
|
+
|
|
114
|
+
// Could be 400 (owner protection) or 409 (transfer lock) — both acceptable
|
|
115
|
+
expect([400, 403, 409]).toContain(res.status);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('changing recipient role while transfer is pending → 409', async () => {
|
|
119
|
+
currentUserId = 1;
|
|
120
|
+
const res = await request(app)
|
|
121
|
+
.patch('/orgs/1/members/2')
|
|
122
|
+
.send({ role: 'member' });
|
|
123
|
+
|
|
124
|
+
expect(res.status).toBe(409);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('after cancelling the transfer, org delete is no longer locked', async () => {
|
|
128
|
+
currentUserId = 1;
|
|
129
|
+
await request(app).delete('/orgs/1/transfer');
|
|
130
|
+
|
|
131
|
+
const deleteRes = await request(app)
|
|
132
|
+
.delete('/orgs/1')
|
|
133
|
+
.send({ confirmOrgName: 'Test Org 1' });
|
|
134
|
+
|
|
135
|
+
// Should no longer be 409 — either succeeds or fails for another reason
|
|
136
|
+
expect(deleteRes.status).not.toBe(409);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('after cancelling the transfer, removing members is no longer locked', async () => {
|
|
140
|
+
currentUserId = 1;
|
|
141
|
+
await request(app).delete('/orgs/1/transfer');
|
|
142
|
+
|
|
143
|
+
const removeRes = await request(app).delete('/orgs/1/members/3');
|
|
144
|
+
expect(removeRes.status).not.toBe(409);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import request from 'supertest';
|
|
5
|
+
import { Pool } from 'pg';
|
|
6
|
+
import { createServerModule } from '../../src/server/index.js';
|
|
7
|
+
import type { ServerModuleAdapter, OrgRole } from '../../src/server/types.js';
|
|
8
|
+
|
|
9
|
+
const describeWithDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
10
|
+
|
|
11
|
+
function sha256(s: string): string {
|
|
12
|
+
return createHash('sha256').update(s).digest('hex');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function extractResetToken(spy: ReturnType<typeof vi.fn>): string {
|
|
16
|
+
const callArg = spy.mock.calls[0]?.[0] as { resetUrl?: string } | undefined;
|
|
17
|
+
const url = callArg?.resetUrl ?? '';
|
|
18
|
+
const qs = url.includes('?') ? url.split('?')[1] : '';
|
|
19
|
+
return new URLSearchParams(qs).get('token') ?? '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let currentUserId: number | null = null;
|
|
23
|
+
let currentOrgId: number | null = null;
|
|
24
|
+
|
|
25
|
+
const sendPasswordResetEmail = vi.fn(async () => {});
|
|
26
|
+
|
|
27
|
+
const testAdapter: ServerModuleAdapter = {
|
|
28
|
+
getCurrentUserId: async () => currentUserId,
|
|
29
|
+
getOrganizationIdForUser: async () => currentOrgId,
|
|
30
|
+
isUserOrgAdmin: async (userId) => userId <= 2,
|
|
31
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
32
|
+
getUserById: async (id) => ({ id, email: `u${id}@test.com`, name: `User${id}` }),
|
|
33
|
+
getUsersByIds: async (ids) => ids.map(id => ({ id, email: `u${id}@test.com`, name: `User${id}` })),
|
|
34
|
+
findUserByEmail: async (email) => {
|
|
35
|
+
const m = email.match(/^u(\d+)@test\.com$/);
|
|
36
|
+
return m ? { id: parseInt(m[1]), email } : null;
|
|
37
|
+
},
|
|
38
|
+
createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({ id: 99, email }),
|
|
39
|
+
setUserPassword: vi.fn(async () => {}),
|
|
40
|
+
hashPassword: async (p) => `h:${p}`,
|
|
41
|
+
verifyPassword: async (p, h) => h === `h:${p}`,
|
|
42
|
+
invalidateAllUserSessions: async () => {},
|
|
43
|
+
sendInviteEmail: async () => {},
|
|
44
|
+
sendOwnershipTransferEmail: async () => {},
|
|
45
|
+
sendEmailChangeVerification: async () => {},
|
|
46
|
+
sendEmailChangeOldNotice: async () => {},
|
|
47
|
+
sendEmailChangedFinalNotice: async () => {},
|
|
48
|
+
sendPasswordResetEmail,
|
|
49
|
+
sendOrgDeletionNotice: async () => {},
|
|
50
|
+
emitNotification: async () => {},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
async function cleanAll(pool: Pool) {
|
|
54
|
+
await pool.query(`TRUNCATE tm_super_admins, tm_password_reset_requests,
|
|
55
|
+
tm_email_change_requests, tm_ownership_transfers, tm_audit_events,
|
|
56
|
+
tm_invitations, tm_memberships, tm_organizations RESTART IDENTITY CASCADE`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function seedOrg(pool: Pool, orgId = 1) {
|
|
60
|
+
await pool.query(`INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings)
|
|
61
|
+
VALUES (${orgId}, 'Test Org ${orgId}', 'test-org-${orgId}', 1, '{}') ON CONFLICT DO NOTHING`);
|
|
62
|
+
await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES
|
|
63
|
+
(${orgId}, 1, 'owner'), (${orgId}, 2, 'admin'), (${orgId}, 3, 'member'), (${orgId}, 4, 'viewer')
|
|
64
|
+
ON CONFLICT DO NOTHING`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describeWithDb('password reset flow', () => {
|
|
68
|
+
let pool: Pool;
|
|
69
|
+
let app: express.Express;
|
|
70
|
+
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
73
|
+
const mod = createServerModule({ adapter: testAdapter, pool, features: {} });
|
|
74
|
+
app = express();
|
|
75
|
+
app.use(express.json());
|
|
76
|
+
app.use(mod.router);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(async () => {
|
|
80
|
+
await pool.end();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
beforeEach(async () => {
|
|
84
|
+
await cleanAll(pool);
|
|
85
|
+
sendPasswordResetEmail.mockClear();
|
|
86
|
+
(testAdapter.setUserPassword as ReturnType<typeof vi.fn>).mockClear?.();
|
|
87
|
+
currentUserId = null;
|
|
88
|
+
currentOrgId = null;
|
|
89
|
+
await seedOrg(pool);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('POST /me/password-reset/request with existing email → 200', async () => {
|
|
93
|
+
const res = await request(app)
|
|
94
|
+
.post('/me/password-reset/request')
|
|
95
|
+
.send({ email: 'u1@test.com' });
|
|
96
|
+
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
expect(sendPasswordResetEmail).toHaveBeenCalledOnce();
|
|
99
|
+
|
|
100
|
+
const dbRow = await pool.query(
|
|
101
|
+
`SELECT * FROM tm_password_reset_requests WHERE user_id = 1 ORDER BY created_at DESC LIMIT 1`
|
|
102
|
+
);
|
|
103
|
+
expect(dbRow.rows.length).toBe(1);
|
|
104
|
+
// used_at is NULL when not yet used
|
|
105
|
+
expect(dbRow.rows[0].used_at).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('POST /me/password-reset/request with unknown email → 200 (no user enumeration)', async () => {
|
|
109
|
+
const res = await request(app)
|
|
110
|
+
.post('/me/password-reset/request')
|
|
111
|
+
.send({ email: 'nonexistent@example.com' });
|
|
112
|
+
|
|
113
|
+
expect(res.status).toBe(200);
|
|
114
|
+
expect(sendPasswordResetEmail).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('POST /me/password-reset with valid token → 200 and password updated', async () => {
|
|
118
|
+
await request(app)
|
|
119
|
+
.post('/me/password-reset/request')
|
|
120
|
+
.send({ email: 'u1@test.com' });
|
|
121
|
+
|
|
122
|
+
// Extract plaintext token from the reset URL sent via mock
|
|
123
|
+
const token = extractResetToken(sendPasswordResetEmail);
|
|
124
|
+
expect(token).toBeTruthy();
|
|
125
|
+
|
|
126
|
+
// Confirm endpoint is POST /me/password-reset (not /confirm)
|
|
127
|
+
const confirmRes = await request(app)
|
|
128
|
+
.post('/me/password-reset')
|
|
129
|
+
.send({ token, newPassword: 'NewSecurePass123!' });
|
|
130
|
+
|
|
131
|
+
expect(confirmRes.status).toBe(200);
|
|
132
|
+
expect(testAdapter.setUserPassword).toHaveBeenCalledOnce();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('POST /me/password-reset with used token → 422', async () => {
|
|
136
|
+
await request(app)
|
|
137
|
+
.post('/me/password-reset/request')
|
|
138
|
+
.send({ email: 'u1@test.com' });
|
|
139
|
+
|
|
140
|
+
const token = extractResetToken(sendPasswordResetEmail);
|
|
141
|
+
expect(token).toBeTruthy();
|
|
142
|
+
|
|
143
|
+
// Use it once
|
|
144
|
+
await request(app)
|
|
145
|
+
.post('/me/password-reset')
|
|
146
|
+
.send({ token, newPassword: 'FirstUse!' });
|
|
147
|
+
|
|
148
|
+
// Try to use again → 422 (Invalid or expired)
|
|
149
|
+
const res = await request(app)
|
|
150
|
+
.post('/me/password-reset')
|
|
151
|
+
.send({ token, newPassword: 'SecondUse!' });
|
|
152
|
+
|
|
153
|
+
expect(res.status).toBe(422);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('POST /me/password-reset with expired token → 422', async () => {
|
|
157
|
+
// Request first to get a real token (captures from mock)
|
|
158
|
+
await request(app)
|
|
159
|
+
.post('/me/password-reset/request')
|
|
160
|
+
.send({ email: 'u1@test.com' });
|
|
161
|
+
const token = extractResetToken(sendPasswordResetEmail);
|
|
162
|
+
expect(token).toBeTruthy();
|
|
163
|
+
|
|
164
|
+
// Force-expire it in DB
|
|
165
|
+
await pool.query(
|
|
166
|
+
`UPDATE tm_password_reset_requests SET expires_at = NOW() - INTERVAL '3 hours' WHERE token_hash = $1`,
|
|
167
|
+
[sha256(token)]
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const res = await request(app)
|
|
171
|
+
.post('/me/password-reset')
|
|
172
|
+
.send({ token, newPassword: 'DoesNotMatter!' });
|
|
173
|
+
|
|
174
|
+
expect(res.status).toBe(422);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('rate limit: 4th password reset request → 200 but email suppressed (silent rate limit)', async () => {
|
|
178
|
+
const recent = new Date(Date.now() - 20 * 60 * 1000).toISOString();
|
|
179
|
+
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
180
|
+
|
|
181
|
+
// Insert 3 existing reset requests with token_hash
|
|
182
|
+
for (let i = 0; i < 3; i++) {
|
|
183
|
+
const fakeToken = `rate-tok-placeholder-${i}`;
|
|
184
|
+
await pool.query(
|
|
185
|
+
`INSERT INTO tm_password_reset_requests (user_id, token_hash, expires_at, created_at)
|
|
186
|
+
VALUES (1, $1, $2, $3)`,
|
|
187
|
+
[sha256(fakeToken), futureExpiry, recent]
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const res = await request(app)
|
|
192
|
+
.post('/me/password-reset/request')
|
|
193
|
+
.send({ email: 'u1@test.com' });
|
|
194
|
+
|
|
195
|
+
// Service silently swallows rate limit (no 429) to prevent email enumeration
|
|
196
|
+
expect(res.status).toBe(200);
|
|
197
|
+
// But the email must NOT have been sent
|
|
198
|
+
expect(sendPasswordResetEmail).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
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 sendPasswordResetEmail = 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,
|
|
37
|
+
sendOrgDeletionNotice: async () => {},
|
|
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
|
+
async function seedOrg(pool: Pool, orgId = 1) {
|
|
48
|
+
await pool.query(`INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings)
|
|
49
|
+
VALUES (${orgId}, 'Test Org ${orgId}', 'test-org-${orgId}', 1, '{}') ON CONFLICT DO NOTHING`);
|
|
50
|
+
await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES
|
|
51
|
+
(${orgId}, 1, 'owner'), (${orgId}, 2, 'admin'), (${orgId}, 3, 'member'), (${orgId}, 4, 'viewer')
|
|
52
|
+
ON CONFLICT DO NOTHING`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describeWithDb('super admin', () => {
|
|
56
|
+
let pool: Pool;
|
|
57
|
+
let app: express.Express;
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
61
|
+
// Enable super admin feature
|
|
62
|
+
const mod = createServerModule({
|
|
63
|
+
adapter: testAdapter,
|
|
64
|
+
pool,
|
|
65
|
+
features: { enableSuperAdmin: true },
|
|
66
|
+
});
|
|
67
|
+
app = express();
|
|
68
|
+
app.use(express.json());
|
|
69
|
+
app.use(mod.router);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
await pool.end();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
beforeEach(async () => {
|
|
77
|
+
await cleanAll(pool);
|
|
78
|
+
sendPasswordResetEmail.mockClear();
|
|
79
|
+
currentOrgId = 1;
|
|
80
|
+
await seedOrg(pool);
|
|
81
|
+
// Register user 1 as super admin
|
|
82
|
+
await pool.query(`INSERT INTO tm_super_admins (user_id) VALUES (1) ON CONFLICT DO NOTHING`);
|
|
83
|
+
currentUserId = 1;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('GET /admin/orgs → 200 with list of orgs', async () => {
|
|
87
|
+
const res = await request(app).get('/admin/orgs');
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
// Route returns { orgs: [...] }
|
|
90
|
+
const orgs = res.body.orgs ?? res.body;
|
|
91
|
+
expect(Array.isArray(orgs)).toBe(true);
|
|
92
|
+
expect(orgs.length).toBeGreaterThanOrEqual(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('POST /admin/orgs/:id/restore → 200 on a deleted org', async () => {
|
|
96
|
+
// Soft-delete the org
|
|
97
|
+
await pool.query(`UPDATE tm_organizations SET deleted_at = NOW() WHERE id = 1`);
|
|
98
|
+
|
|
99
|
+
const res = await request(app)
|
|
100
|
+
.post('/admin/orgs/1/restore')
|
|
101
|
+
.send({ reason: 'test restore' });
|
|
102
|
+
expect(res.status).toBe(200);
|
|
103
|
+
|
|
104
|
+
const row = await pool.query(`SELECT deleted_at FROM tm_organizations WHERE id = 1`);
|
|
105
|
+
expect(row.rows[0].deleted_at).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('POST /admin/orgs/:id/appoint-owner → 200, audit event with actor_type=super_admin', async () => {
|
|
109
|
+
const res = await request(app)
|
|
110
|
+
.post('/admin/orgs/1/appoint-owner')
|
|
111
|
+
.send({ targetUserId: 2, reason: 'test' });
|
|
112
|
+
|
|
113
|
+
expect(res.status).toBe(200);
|
|
114
|
+
|
|
115
|
+
// Verify new owner in DB
|
|
116
|
+
const org = await pool.query(`SELECT owner_user_id FROM tm_organizations WHERE id = 1`);
|
|
117
|
+
expect(org.rows[0].owner_user_id).toBe(2);
|
|
118
|
+
|
|
119
|
+
// Reason is stored in audit reason column
|
|
120
|
+
const adminEvent = await pool.query(
|
|
121
|
+
`SELECT reason FROM tm_audit_events WHERE actor_type = 'super_admin' ORDER BY created_at DESC LIMIT 1`
|
|
122
|
+
);
|
|
123
|
+
if (adminEvent.rows.length > 0) {
|
|
124
|
+
expect(adminEvent.rows[0].reason).toContain('test');
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('POST /admin/users/:userId/lock → 200', async () => {
|
|
129
|
+
const res = await request(app)
|
|
130
|
+
.post('/admin/users/3/lock')
|
|
131
|
+
.send({ reason: 'spam' });
|
|
132
|
+
|
|
133
|
+
expect(res.status).toBe(200);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('POST /admin/users/:userId/password-reset → 200 and calls sendPasswordResetEmail', async () => {
|
|
137
|
+
const res = await request(app)
|
|
138
|
+
.post('/admin/users/3/password-reset')
|
|
139
|
+
.send({ reason: 'support request' });
|
|
140
|
+
|
|
141
|
+
expect(res.status).toBe(200);
|
|
142
|
+
expect(sendPasswordResetEmail).toHaveBeenCalledOnce();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('audit events from super-admin actions show actor_type = "super_admin"', async () => {
|
|
146
|
+
await request(app)
|
|
147
|
+
.post('/admin/orgs/1/appoint-owner')
|
|
148
|
+
.send({ targetUserId: 2, reason: 'audit test' });
|
|
149
|
+
|
|
150
|
+
const events = await pool.query(
|
|
151
|
+
`SELECT * FROM tm_audit_events WHERE actor_type = 'super_admin'`
|
|
152
|
+
);
|
|
153
|
+
expect(events.rows.length).toBeGreaterThanOrEqual(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('non-super-admin accessing /admin/* → 403', async () => {
|
|
157
|
+
currentUserId = 2; // admin but not super-admin
|
|
158
|
+
|
|
159
|
+
const res = await request(app).get('/admin/orgs');
|
|
160
|
+
expect(res.status).toBe(403);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('unauthenticated user accessing /admin/* → 401 or 403', async () => {
|
|
164
|
+
currentUserId = null;
|
|
165
|
+
|
|
166
|
+
const res = await request(app).get('/admin/orgs');
|
|
167
|
+
expect([401, 403]).toContain(res.status);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('GET /admin/orgs includes soft-deleted orgs', async () => {
|
|
171
|
+
await pool.query(`UPDATE tm_organizations SET deleted_at = NOW() WHERE id = 1`);
|
|
172
|
+
|
|
173
|
+
const res = await request(app).get('/admin/orgs');
|
|
174
|
+
expect(res.status).toBe(200);
|
|
175
|
+
|
|
176
|
+
const orgs = (res.body.orgs ?? res.body) as Array<{ id: number }>;
|
|
177
|
+
const ids = orgs.map(o => o.id);
|
|
178
|
+
expect(ids).toContain(1);
|
|
179
|
+
});
|
|
180
|
+
});
|