@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,190 @@
|
|
|
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
|
+
let currentUserId: number | null = null;
|
|
16
|
+
let currentOrgId: number | null = null;
|
|
17
|
+
|
|
18
|
+
const sendEmailChangeVerification = vi.fn(async () => {});
|
|
19
|
+
const sendEmailChangeOldNotice = vi.fn(async () => {});
|
|
20
|
+
const sendEmailChangedFinalNotice = vi.fn(async () => {});
|
|
21
|
+
const sendPasswordResetEmail = vi.fn(async () => {});
|
|
22
|
+
|
|
23
|
+
const testAdapter: ServerModuleAdapter = {
|
|
24
|
+
getCurrentUserId: async () => currentUserId,
|
|
25
|
+
getOrganizationIdForUser: async () => currentOrgId,
|
|
26
|
+
isUserOrgAdmin: async (userId) => userId <= 2,
|
|
27
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
28
|
+
getUserById: async (id) => ({ id, email: `u${id}@test.com`, name: `User${id}` }),
|
|
29
|
+
getUsersByIds: async (ids) => ids.map(id => ({ id, email: `u${id}@test.com`, name: `User${id}` })),
|
|
30
|
+
findUserByEmail: async (email) => {
|
|
31
|
+
const m = email.match(/^u(\d+)@test\.com$/);
|
|
32
|
+
return m ? { id: parseInt(m[1]), email } : null;
|
|
33
|
+
},
|
|
34
|
+
createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({ id: 99, email }),
|
|
35
|
+
setUserPassword: async () => {},
|
|
36
|
+
hashPassword: async (p) => `h:${p}`,
|
|
37
|
+
verifyPassword: async (p, h) => h === `h:${p}`,
|
|
38
|
+
invalidateAllUserSessions: async () => {},
|
|
39
|
+
sendInviteEmail: async () => {},
|
|
40
|
+
sendOwnershipTransferEmail: async () => {},
|
|
41
|
+
sendEmailChangeVerification,
|
|
42
|
+
sendEmailChangeOldNotice,
|
|
43
|
+
sendEmailChangedFinalNotice,
|
|
44
|
+
sendPasswordResetEmail,
|
|
45
|
+
sendOrgDeletionNotice: async () => {},
|
|
46
|
+
emitNotification: async () => {},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
async function cleanAll(pool: Pool) {
|
|
50
|
+
await pool.query(`TRUNCATE tm_super_admins, tm_password_reset_requests,
|
|
51
|
+
tm_email_change_requests, tm_ownership_transfers, tm_audit_events,
|
|
52
|
+
tm_invitations, tm_memberships, tm_organizations RESTART IDENTITY CASCADE`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function seedOrg(pool: Pool, orgId = 1) {
|
|
56
|
+
await pool.query(`INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings)
|
|
57
|
+
VALUES (${orgId}, 'Test Org ${orgId}', 'test-org-${orgId}', 1, '{}') ON CONFLICT DO NOTHING`);
|
|
58
|
+
await pool.query(`INSERT INTO tm_memberships (org_id, user_id, role) VALUES
|
|
59
|
+
(${orgId}, 1, 'owner'), (${orgId}, 2, 'admin'), (${orgId}, 3, 'member'), (${orgId}, 4, 'viewer')
|
|
60
|
+
ON CONFLICT DO NOTHING`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describeWithDb('email change flow', () => {
|
|
64
|
+
let pool: Pool;
|
|
65
|
+
let app: express.Express;
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
69
|
+
const mod = createServerModule({ adapter: testAdapter, pool, features: {} });
|
|
70
|
+
app = express();
|
|
71
|
+
app.use(express.json());
|
|
72
|
+
app.use(mod.router);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
await pool.end();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
await cleanAll(pool);
|
|
81
|
+
sendEmailChangeVerification.mockClear();
|
|
82
|
+
sendEmailChangeOldNotice.mockClear();
|
|
83
|
+
sendEmailChangedFinalNotice.mockClear();
|
|
84
|
+
sendPasswordResetEmail.mockClear();
|
|
85
|
+
currentUserId = 3;
|
|
86
|
+
currentOrgId = 1;
|
|
87
|
+
await seedOrg(pool);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('POST /me/email-change → 200 and calls verification + notice emails', async () => {
|
|
91
|
+
const res = await request(app)
|
|
92
|
+
.post('/me/email-change')
|
|
93
|
+
.send({ newEmail: 'new@example.com', currentPassword: 'mypassword' });
|
|
94
|
+
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
|
|
97
|
+
// Both verification and old-address notice should be sent
|
|
98
|
+
expect(sendEmailChangeVerification).toHaveBeenCalledOnce();
|
|
99
|
+
expect(sendEmailChangeOldNotice).toHaveBeenCalledOnce();
|
|
100
|
+
|
|
101
|
+
// Verify row in DB
|
|
102
|
+
const dbRow = await pool.query(
|
|
103
|
+
`SELECT * FROM tm_email_change_requests WHERE user_id = 3 ORDER BY created_at DESC LIMIT 1`
|
|
104
|
+
);
|
|
105
|
+
expect(dbRow.rows.length).toBe(1);
|
|
106
|
+
expect(dbRow.rows[0].new_email).toBe('new@example.com');
|
|
107
|
+
// Status is tracked via verified_at/cancelled_at (both null = pending)
|
|
108
|
+
expect(dbRow.rows[0].verified_at).toBeNull();
|
|
109
|
+
expect(dbRow.rows[0].cancelled_at).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('GET /me/email-change/verify?token=:token → 200, email changed', async () => {
|
|
113
|
+
sendEmailChangeVerification.mockClear();
|
|
114
|
+
await request(app)
|
|
115
|
+
.post('/me/email-change')
|
|
116
|
+
.send({ newEmail: 'verified@example.com', currentPassword: 'mypassword' });
|
|
117
|
+
|
|
118
|
+
// Extract raw token from the verifyUrl sent to the spy
|
|
119
|
+
const callArg = sendEmailChangeVerification.mock.calls[0]?.[0] as { verifyUrl?: string } | undefined;
|
|
120
|
+
const verifyUrl = callArg?.verifyUrl ?? '';
|
|
121
|
+
const token = new URLSearchParams(verifyUrl.split('?')[1] ?? '').get('token') ?? '';
|
|
122
|
+
expect(token).toBeTruthy();
|
|
123
|
+
|
|
124
|
+
// Route requires auth — keep currentUserId=3
|
|
125
|
+
const res = await request(app)
|
|
126
|
+
.get(`/me/email-change/verify?token=${token}`);
|
|
127
|
+
|
|
128
|
+
expect(res.status).toBe(200);
|
|
129
|
+
// sendEmailChangedFinalNotice is called twice (for new address and old address)
|
|
130
|
+
expect(sendEmailChangedFinalNotice).toHaveBeenCalledTimes(2);
|
|
131
|
+
|
|
132
|
+
// Verify DB shows verified_at is set
|
|
133
|
+
const tokenHash = sha256(token);
|
|
134
|
+
const updated = await pool.query(
|
|
135
|
+
`SELECT verified_at FROM tm_email_change_requests WHERE verify_token_hash = $1`,
|
|
136
|
+
[tokenHash]
|
|
137
|
+
);
|
|
138
|
+
expect(updated.rows[0].verified_at).not.toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('GET /me/email-change/cancel?token=:cancelToken → 200, change cancelled', async () => {
|
|
142
|
+
sendEmailChangeOldNotice.mockClear();
|
|
143
|
+
await request(app)
|
|
144
|
+
.post('/me/email-change')
|
|
145
|
+
.send({ newEmail: 'cancel@example.com', currentPassword: 'mypassword' });
|
|
146
|
+
|
|
147
|
+
// Extract raw cancel token from the cancelUrl sent to the spy
|
|
148
|
+
const cancelCallArg = sendEmailChangeOldNotice.mock.calls[0]?.[0] as { cancelUrl?: string } | undefined;
|
|
149
|
+
const cancelUrl = cancelCallArg?.cancelUrl ?? '';
|
|
150
|
+
const cancelToken = new URLSearchParams(cancelUrl.split('?')[1] ?? '').get('token') ?? '';
|
|
151
|
+
expect(cancelToken).toBeTruthy();
|
|
152
|
+
|
|
153
|
+
currentUserId = null; // cancel is unauthenticated — token is the credential
|
|
154
|
+
const res = await request(app)
|
|
155
|
+
.get(`/me/email-change/cancel?token=${cancelToken}`);
|
|
156
|
+
|
|
157
|
+
expect(res.status).toBe(200);
|
|
158
|
+
|
|
159
|
+
// Verify DB shows cancelled_at is set
|
|
160
|
+
const cancelTokenHash = sha256(cancelToken);
|
|
161
|
+
const updated = await pool.query(
|
|
162
|
+
`SELECT cancelled_at FROM tm_email_change_requests WHERE cancel_token_hash = $1`,
|
|
163
|
+
[cancelTokenHash]
|
|
164
|
+
);
|
|
165
|
+
expect(updated.rows[0].cancelled_at).not.toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it.skip('after cancel: sendPasswordResetEmail is triggered (not yet implemented in service)', async () => {
|
|
169
|
+
// cancelEmailChange service calls invalidateAllUserSessions but not sendPasswordResetEmail.
|
|
170
|
+
// Skipped until the security-triggered password-reset-on-cancel flow is implemented.
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rate limit: 4th email-change request in 24h → 429', async () => {
|
|
174
|
+
// Insert 3 existing requests within the last 24 hours
|
|
175
|
+
const past = new Date(Date.now() - 30 * 60 * 1000).toISOString(); // 30 min ago
|
|
176
|
+
for (let i = 0; i < 3; i++) {
|
|
177
|
+
await pool.query(
|
|
178
|
+
`INSERT INTO tm_email_change_requests (user_id, new_email, verify_token_hash, cancel_token_hash, expires_at, created_at)
|
|
179
|
+
VALUES (3, $1, $2, $3, NOW() + INTERVAL '24 hours', $4)`,
|
|
180
|
+
[`rate${i}@example.com`, sha256(`verify-rate${i}`), sha256(`cancel-rate${i}`), past]
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const res = await request(app)
|
|
185
|
+
.post('/me/email-change')
|
|
186
|
+
.send({ newEmail: 'fourth@example.com', currentPassword: 'mypassword' });
|
|
187
|
+
|
|
188
|
+
expect(res.status).toBe(429);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
function buildApp(pool: Pool, features: Record<string, boolean>) {
|
|
54
|
+
const mod = createServerModule({ adapter: testAdapter, pool, features });
|
|
55
|
+
const app = express();
|
|
56
|
+
app.use(express.json());
|
|
57
|
+
app.use(mod.router);
|
|
58
|
+
return app;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describeWithDb('feature flags', () => {
|
|
62
|
+
let pool: Pool;
|
|
63
|
+
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await pool.end();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
await cleanAll(pool);
|
|
74
|
+
currentUserId = 1;
|
|
75
|
+
currentOrgId = 1;
|
|
76
|
+
await seedOrg(pool);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('enableInvites = false', () => {
|
|
80
|
+
it('POST /orgs/1/invitations → 501', async () => {
|
|
81
|
+
const app = buildApp(pool, { enableInvites: false });
|
|
82
|
+
const res = await request(app)
|
|
83
|
+
.post('/orgs/1/invitations')
|
|
84
|
+
.send({ email: 'test@example.com', role: 'member' });
|
|
85
|
+
expect(res.status).toBe(501);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('enableAuditLog = false', () => {
|
|
90
|
+
it('GET /orgs/1/audit → 501', async () => {
|
|
91
|
+
const app = buildApp(pool, { enableAuditLog: false });
|
|
92
|
+
const res = await request(app).get('/orgs/1/audit');
|
|
93
|
+
expect(res.status).toBe(501);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('enableOwnershipTransfer = false', () => {
|
|
98
|
+
it('POST /orgs/1/transfer → 501', async () => {
|
|
99
|
+
const app = buildApp(pool, { enableOwnershipTransfer: false });
|
|
100
|
+
const res = await request(app)
|
|
101
|
+
.post('/orgs/1/transfer')
|
|
102
|
+
.send({ toUserId: 2, confirmOrgName: 'Test Org 1' });
|
|
103
|
+
expect(res.status).toBe(501);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('enableEmailChange = false', () => {
|
|
108
|
+
it('POST /me/email-change → 501', async () => {
|
|
109
|
+
const app = buildApp(pool, { enableEmailChange: false });
|
|
110
|
+
const res = await request(app)
|
|
111
|
+
.post('/me/email-change')
|
|
112
|
+
.send({ newEmail: 'new@example.com', currentPassword: 'pass' });
|
|
113
|
+
expect(res.status).toBe(501);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('enablePasswordReset = false', () => {
|
|
118
|
+
it('POST /me/password-reset/request → 501', async () => {
|
|
119
|
+
const app = buildApp(pool, { enablePasswordReset: false });
|
|
120
|
+
currentUserId = null;
|
|
121
|
+
const res = await request(app)
|
|
122
|
+
.post('/me/password-reset/request')
|
|
123
|
+
.send({ email: 'u1@test.com' });
|
|
124
|
+
expect(res.status).toBe(501);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('enableSuperAdmin = false', () => {
|
|
129
|
+
it('GET /admin/orgs → 403, 404, or 501', async () => {
|
|
130
|
+
const app = buildApp(pool, { enableSuperAdmin: false });
|
|
131
|
+
const res = await request(app).get('/admin/orgs');
|
|
132
|
+
// 404 when feature disabled (middleware returns 404 to obscure admin routes)
|
|
133
|
+
// 403 if user not super-admin, 501 if feature check first
|
|
134
|
+
expect([403, 404, 501]).toContain(res.status);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('all flags enabled', () => {
|
|
139
|
+
it('POST /orgs/1/invitations returns non-501', async () => {
|
|
140
|
+
const app = buildApp(pool, {
|
|
141
|
+
enableInvites: true,
|
|
142
|
+
enableAuditLog: true,
|
|
143
|
+
enableOwnershipTransfer: true,
|
|
144
|
+
enableEmailChange: true,
|
|
145
|
+
enablePasswordReset: true,
|
|
146
|
+
enableSuperAdmin: true,
|
|
147
|
+
});
|
|
148
|
+
const res = await request(app)
|
|
149
|
+
.post('/orgs/1/invitations')
|
|
150
|
+
.send({ email: 'test@example.com', role: 'member' });
|
|
151
|
+
expect(res.status).not.toBe(501);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('GET /orgs/1/audit returns non-501', async () => {
|
|
155
|
+
const app = buildApp(pool, {
|
|
156
|
+
enableInvites: true,
|
|
157
|
+
enableAuditLog: true,
|
|
158
|
+
enableOwnershipTransfer: true,
|
|
159
|
+
enableEmailChange: true,
|
|
160
|
+
enablePasswordReset: true,
|
|
161
|
+
enableSuperAdmin: true,
|
|
162
|
+
});
|
|
163
|
+
const res = await request(app).get('/orgs/1/audit');
|
|
164
|
+
expect(res.status).not.toBe(501);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('POST /orgs/1/transfer returns non-501', async () => {
|
|
168
|
+
const app = buildApp(pool, {
|
|
169
|
+
enableInvites: true,
|
|
170
|
+
enableAuditLog: true,
|
|
171
|
+
enableOwnershipTransfer: true,
|
|
172
|
+
enableEmailChange: true,
|
|
173
|
+
enablePasswordReset: true,
|
|
174
|
+
enableSuperAdmin: true,
|
|
175
|
+
});
|
|
176
|
+
const res = await request(app)
|
|
177
|
+
.post('/orgs/1/transfer')
|
|
178
|
+
.send({ toUserId: 2, confirmOrgName: 'Test Org 1' });
|
|
179
|
+
expect(res.status).not.toBe(501);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('POST /me/email-change returns non-501', async () => {
|
|
183
|
+
const app = buildApp(pool, {
|
|
184
|
+
enableInvites: true,
|
|
185
|
+
enableAuditLog: true,
|
|
186
|
+
enableOwnershipTransfer: true,
|
|
187
|
+
enableEmailChange: true,
|
|
188
|
+
enablePasswordReset: true,
|
|
189
|
+
enableSuperAdmin: true,
|
|
190
|
+
});
|
|
191
|
+
const res = await request(app)
|
|
192
|
+
.post('/me/email-change')
|
|
193
|
+
.send({ newEmail: 'new@example.com', currentPassword: 'pass' });
|
|
194
|
+
expect(res.status).not.toBe(501);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('POST /me/password-reset/request returns non-501', async () => {
|
|
198
|
+
const app = buildApp(pool, {
|
|
199
|
+
enableInvites: true,
|
|
200
|
+
enableAuditLog: true,
|
|
201
|
+
enableOwnershipTransfer: true,
|
|
202
|
+
enableEmailChange: true,
|
|
203
|
+
enablePasswordReset: true,
|
|
204
|
+
enableSuperAdmin: true,
|
|
205
|
+
});
|
|
206
|
+
currentUserId = null;
|
|
207
|
+
const res = await request(app)
|
|
208
|
+
.post('/me/password-reset/request')
|
|
209
|
+
.send({ email: 'u1@test.com' });
|
|
210
|
+
expect(res.status).not.toBe(501);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
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 DATABASE_URL = process.env.DATABASE_URL;
|
|
9
|
+
|
|
10
|
+
describe.skipIf(!DATABASE_URL)('Invitations – Code Accept', () => {
|
|
11
|
+
let pool: Pool;
|
|
12
|
+
let app: express.Express;
|
|
13
|
+
let currentUserId: number | null = null;
|
|
14
|
+
let currentOrgId: number | null = null;
|
|
15
|
+
|
|
16
|
+
const testAdapter: ServerModuleAdapter = {
|
|
17
|
+
getCurrentUserId: async () => currentUserId,
|
|
18
|
+
getOrganizationIdForUser: async () => currentOrgId,
|
|
19
|
+
isUserOrgAdmin: async (userId, _orgId) => userId === 1 || userId === 2,
|
|
20
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
21
|
+
getUserById: async (id) => {
|
|
22
|
+
const users: Record<number, { id: number; email: string; name: string }> = {
|
|
23
|
+
1: { id: 1, email: 'owner@test.com', name: 'Owner' },
|
|
24
|
+
2: { id: 2, email: 'admin@test.com', name: 'Admin' },
|
|
25
|
+
3: { id: 3, email: 'member@test.com', name: 'Member' },
|
|
26
|
+
4: { id: 4, email: 'viewer@test.com', name: 'Viewer' },
|
|
27
|
+
5: { id: 5, email: 'outsider@test.com', name: 'Outsider' },
|
|
28
|
+
};
|
|
29
|
+
return users[id] ?? null;
|
|
30
|
+
},
|
|
31
|
+
getUsersByIds: async (ids) =>
|
|
32
|
+
ids.map((id) => ({ id, email: `user${id}@test.com`, name: `User${id}` })),
|
|
33
|
+
findUserByEmail: async (email) => {
|
|
34
|
+
const map: Record<string, { id: number; email: string }> = {
|
|
35
|
+
'owner@test.com': { id: 1, email: 'owner@test.com' },
|
|
36
|
+
'admin@test.com': { id: 2, email: 'admin@test.com' },
|
|
37
|
+
'member@test.com': { id: 3, email: 'member@test.com' },
|
|
38
|
+
'outsider@test.com': { id: 5, email: 'outsider@test.com' },
|
|
39
|
+
'new@test.com': { id: 6, email: 'new@test.com' },
|
|
40
|
+
};
|
|
41
|
+
return map[email] ?? null;
|
|
42
|
+
},
|
|
43
|
+
createUserFromInvite: async ({ email }: { email: string; orgId: number; role: OrgRole }) => ({
|
|
44
|
+
id: 99,
|
|
45
|
+
email,
|
|
46
|
+
}),
|
|
47
|
+
setUserPassword: async () => {},
|
|
48
|
+
hashPassword: async (p) => `h:${p}`,
|
|
49
|
+
verifyPassword: async (p, h) => h === `h:${p}`,
|
|
50
|
+
invalidateAllUserSessions: async () => {},
|
|
51
|
+
sendInviteEmail: async () => {},
|
|
52
|
+
sendOwnershipTransferEmail: async () => {},
|
|
53
|
+
sendEmailChangeVerification: async () => {},
|
|
54
|
+
sendEmailChangeOldNotice: async () => {},
|
|
55
|
+
sendEmailChangedFinalNotice: async () => {},
|
|
56
|
+
sendPasswordResetEmail: async () => {},
|
|
57
|
+
sendOrgDeletionNotice: async () => {},
|
|
58
|
+
emitNotification: async () => {},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
pool = new Pool({ connectionString: DATABASE_URL });
|
|
63
|
+
|
|
64
|
+
const serverModule = createServerModule({ adapter: testAdapter, pool, features: {
|
|
65
|
+
enableInvites: true,
|
|
66
|
+
enableAuditLog: false,
|
|
67
|
+
} });
|
|
68
|
+
|
|
69
|
+
await pool.query(`DELETE FROM tm_memberships WHERE org_id = 1`);
|
|
70
|
+
await pool.query(`DELETE FROM tm_organizations WHERE id = 1`);
|
|
71
|
+
await pool.query(
|
|
72
|
+
`INSERT INTO tm_organizations (id, name, slug, owner_user_id, settings) VALUES (1, 'Test Org', 'test-org', 1, '{}')`
|
|
73
|
+
);
|
|
74
|
+
await pool.query(
|
|
75
|
+
`INSERT INTO tm_memberships (org_id, user_id, role) VALUES (1, 1, 'owner'), (1, 2, 'admin'), (1, 3, 'member'), (1, 4, 'viewer')`
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
app = express();
|
|
79
|
+
app.use(express.json());
|
|
80
|
+
app.use(serverModule.router);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await pool.query(`DELETE FROM tm_invitations WHERE org_id = 1`);
|
|
85
|
+
await pool.query(`DELETE FROM tm_memberships WHERE org_id = 1`);
|
|
86
|
+
await pool.query(`DELETE FROM tm_organizations WHERE id = 1`);
|
|
87
|
+
await pool.end();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
currentUserId = null;
|
|
92
|
+
currentOrgId = null;
|
|
93
|
+
await pool.query(`DELETE FROM tm_invitations WHERE org_id = 1`);
|
|
94
|
+
await pool.query(`DELETE FROM tm_memberships WHERE org_id = 1 AND user_id = 5`);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('POST /orgs/1/invitations as admin creates invite (201)', async () => {
|
|
98
|
+
currentUserId = 2;
|
|
99
|
+
currentOrgId = 1;
|
|
100
|
+
|
|
101
|
+
const res = await request(app)
|
|
102
|
+
.post('/orgs/1/invitations')
|
|
103
|
+
.send({ email: 'outsider@test.com', role: 'viewer' });
|
|
104
|
+
|
|
105
|
+
expect(res.status).toBe(201);
|
|
106
|
+
expect(res.body).toMatchObject({
|
|
107
|
+
invitation: expect.objectContaining({
|
|
108
|
+
email: 'outsider@test.com',
|
|
109
|
+
role: 'viewer',
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('GET /orgs/1/invitations/:id/code as admin returns plaintext 6-digit code (200)', async () => {
|
|
115
|
+
currentUserId = 2;
|
|
116
|
+
currentOrgId = 1;
|
|
117
|
+
|
|
118
|
+
const createRes = await request(app)
|
|
119
|
+
.post('/orgs/1/invitations')
|
|
120
|
+
.send({ email: 'outsider@test.com', role: 'viewer' });
|
|
121
|
+
expect(createRes.status).toBe(201);
|
|
122
|
+
const inviteId = createRes.body.invitation?.id ?? createRes.body.id;
|
|
123
|
+
|
|
124
|
+
const res = await request(app).get(`/orgs/1/invitations/${inviteId}/code`);
|
|
125
|
+
expect(res.status).toBe(200);
|
|
126
|
+
expect(res.body.code).toBeDefined();
|
|
127
|
+
expect(/^\d{6}$/.test(String(res.body.code))).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('GET /orgs/1/invitations/:id/code as member → 403', async () => {
|
|
131
|
+
currentUserId = 2;
|
|
132
|
+
currentOrgId = 1;
|
|
133
|
+
|
|
134
|
+
const createRes = await request(app)
|
|
135
|
+
.post('/orgs/1/invitations')
|
|
136
|
+
.send({ email: 'outsider@test.com', role: 'viewer' });
|
|
137
|
+
expect(createRes.status).toBe(201);
|
|
138
|
+
const inviteId = createRes.body.invitation?.id ?? createRes.body.id;
|
|
139
|
+
|
|
140
|
+
currentUserId = 3;
|
|
141
|
+
currentOrgId = 1;
|
|
142
|
+
|
|
143
|
+
const res = await request(app).get(`/orgs/1/invitations/${inviteId}/code`);
|
|
144
|
+
expect(res.status).toBe(403);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('POST /invitations/accept/code with valid code and matching email → 200', async () => {
|
|
148
|
+
currentUserId = 2;
|
|
149
|
+
currentOrgId = 1;
|
|
150
|
+
|
|
151
|
+
const createRes = await request(app)
|
|
152
|
+
.post('/orgs/1/invitations')
|
|
153
|
+
.send({ email: 'outsider@test.com', role: 'member' });
|
|
154
|
+
expect(createRes.status).toBe(201);
|
|
155
|
+
const inviteId = createRes.body.invitation?.id ?? createRes.body.id;
|
|
156
|
+
|
|
157
|
+
const codeRes = await request(app).get(`/orgs/1/invitations/${inviteId}/code`);
|
|
158
|
+
expect(codeRes.status).toBe(200);
|
|
159
|
+
const code = codeRes.body.code;
|
|
160
|
+
|
|
161
|
+
currentUserId = 5;
|
|
162
|
+
currentOrgId = null;
|
|
163
|
+
|
|
164
|
+
const res = await request(app)
|
|
165
|
+
.post('/invitations/accept/code')
|
|
166
|
+
.send({ email: 'outsider@test.com', code });
|
|
167
|
+
|
|
168
|
+
expect(res.status).toBe(200);
|
|
169
|
+
|
|
170
|
+
const membershipRow = await pool.query(
|
|
171
|
+
`SELECT * FROM tm_memberships WHERE org_id = 1 AND user_id = 5`
|
|
172
|
+
);
|
|
173
|
+
expect(membershipRow.rows.length).toBe(1);
|
|
174
|
+
expect(membershipRow.rows[0].role).toBe('member');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('POST /invitations/accept/code with wrong code → 400 or 404', async () => {
|
|
178
|
+
currentUserId = 2;
|
|
179
|
+
currentOrgId = 1;
|
|
180
|
+
|
|
181
|
+
const createRes = await request(app)
|
|
182
|
+
.post('/orgs/1/invitations')
|
|
183
|
+
.send({ email: 'outsider@test.com', role: 'member' });
|
|
184
|
+
expect(createRes.status).toBe(201);
|
|
185
|
+
|
|
186
|
+
currentUserId = 5;
|
|
187
|
+
currentOrgId = null;
|
|
188
|
+
|
|
189
|
+
const res = await request(app)
|
|
190
|
+
.post('/invitations/accept/code')
|
|
191
|
+
.send({ email: 'outsider@test.com', code: '000000' });
|
|
192
|
+
|
|
193
|
+
expect([400, 404]).toContain(res.status);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('POST /invitations/accept/code with wrong email → 400 or 404', async () => {
|
|
197
|
+
currentUserId = 2;
|
|
198
|
+
currentOrgId = 1;
|
|
199
|
+
|
|
200
|
+
const createRes = await request(app)
|
|
201
|
+
.post('/orgs/1/invitations')
|
|
202
|
+
.send({ email: 'outsider@test.com', role: 'member' });
|
|
203
|
+
expect(createRes.status).toBe(201);
|
|
204
|
+
const inviteId = createRes.body.invitation?.id ?? createRes.body.id;
|
|
205
|
+
|
|
206
|
+
const codeRes = await request(app).get(`/orgs/1/invitations/${inviteId}/code`);
|
|
207
|
+
const code = codeRes.body.code;
|
|
208
|
+
|
|
209
|
+
currentUserId = null;
|
|
210
|
+
currentOrgId = null;
|
|
211
|
+
|
|
212
|
+
const res = await request(app)
|
|
213
|
+
.post('/invitations/accept/code')
|
|
214
|
+
.send({ email: 'wrongperson@test.com', code });
|
|
215
|
+
|
|
216
|
+
expect([400, 404]).toContain(res.status);
|
|
217
|
+
});
|
|
218
|
+
});
|