@actuate-media/cms-core 0.36.0 → 0.38.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/dist/__tests__/api/security-routes.test.d.ts +2 -0
- package/dist/__tests__/api/security-routes.test.d.ts.map +1 -0
- package/dist/__tests__/api/security-routes.test.js +212 -0
- package/dist/__tests__/api/security-routes.test.js.map +1 -0
- package/dist/__tests__/api/totp-login.test.d.ts +2 -0
- package/dist/__tests__/api/totp-login.test.d.ts.map +1 -0
- package/dist/__tests__/api/totp-login.test.js +115 -0
- package/dist/__tests__/api/totp-login.test.js.map +1 -0
- package/dist/__tests__/auth/totp.test.d.ts +2 -0
- package/dist/__tests__/auth/totp.test.d.ts.map +1 -0
- package/dist/__tests__/auth/totp.test.js +52 -0
- package/dist/__tests__/auth/totp.test.js.map +1 -0
- package/dist/__tests__/security/center.test.d.ts +2 -0
- package/dist/__tests__/security/center.test.d.ts.map +1 -0
- package/dist/__tests__/security/center.test.js +226 -0
- package/dist/__tests__/security/center.test.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +240 -8
- package/dist/api/handlers.js.map +1 -1
- package/dist/auth/totp.d.ts +12 -0
- package/dist/auth/totp.d.ts.map +1 -1
- package/dist/auth/totp.js +24 -0
- package/dist/auth/totp.js.map +1 -1
- package/dist/security/center/gather.d.ts +12 -0
- package/dist/security/center/gather.d.ts.map +1 -0
- package/dist/security/center/gather.js +185 -0
- package/dist/security/center/gather.js.map +1 -0
- package/dist/security/center/index.d.ts +13 -0
- package/dist/security/center/index.d.ts.map +1 -0
- package/dist/security/center/index.js +4 -0
- package/dist/security/center/index.js.map +1 -0
- package/dist/security/center/settings.d.ts +38 -0
- package/dist/security/center/settings.d.ts.map +1 -0
- package/dist/security/center/settings.js +121 -0
- package/dist/security/center/settings.js.map +1 -0
- package/dist/security/center/status.d.ts +10 -0
- package/dist/security/center/status.d.ts.map +1 -0
- package/dist/security/center/status.js +249 -0
- package/dist/security/center/status.js.map +1 -0
- package/dist/security/center/summaries.d.ts +21 -0
- package/dist/security/center/summaries.d.ts.map +1 -0
- package/dist/security/center/summaries.js +65 -0
- package/dist/security/center/summaries.js.map +1 -0
- package/dist/security/center/types.d.ts +127 -0
- package/dist/security/center/types.d.ts.map +1 -0
- package/dist/security/center/types.js +12 -0
- package/dist/security/center/types.js.map +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"security-routes.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/api/security-routes.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { handleActuateAPI } from '../../api/index.js';
|
|
3
|
+
import { initDB } from '../../db.js';
|
|
4
|
+
import { generateApiKey } from '../../security/api-key-enhanced.js';
|
|
5
|
+
/**
|
|
6
|
+
* Contract tests for the Security Center endpoints:
|
|
7
|
+
* - GET /security → authed (admin), computed posture.
|
|
8
|
+
* - PUT /security → persists enforced policy, lockout-safe.
|
|
9
|
+
* - POST /security/sessions/revoke-all → revokes the caller's other sessions.
|
|
10
|
+
*
|
|
11
|
+
* Proves the response never leaks secret values, that lockout-safe MFA enabling
|
|
12
|
+
* is enforced server-side, and that policy round-trips through the settings global.
|
|
13
|
+
*/
|
|
14
|
+
const VALID_SECRET = 'a'.repeat(48);
|
|
15
|
+
function createMockDB(state, apiKey) {
|
|
16
|
+
const tx = {
|
|
17
|
+
document: {
|
|
18
|
+
update: async ({ data }) => {
|
|
19
|
+
state.settings = data.data;
|
|
20
|
+
return { id: 'settings-doc', data: state.settings };
|
|
21
|
+
},
|
|
22
|
+
create: async ({ data }) => {
|
|
23
|
+
state.settings = data.data;
|
|
24
|
+
return { id: 'settings-doc', data: state.settings };
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
version: { create: async () => ({ id: 'v1' }) },
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
apiKey: {
|
|
31
|
+
findMany: async () => (apiKey ? [apiKey] : []),
|
|
32
|
+
findUnique: async ({ where }) => apiKey && apiKey.keyHash === where.keyHash ? apiKey : null,
|
|
33
|
+
update: async () => apiKey,
|
|
34
|
+
},
|
|
35
|
+
document: {
|
|
36
|
+
findFirst: async () => ({ id: 'settings-doc', data: state.settings }),
|
|
37
|
+
findMany: async () => [],
|
|
38
|
+
count: async () => 0,
|
|
39
|
+
},
|
|
40
|
+
version: { create: async () => ({ id: 'v1' }) },
|
|
41
|
+
user: {
|
|
42
|
+
findMany: async () => [
|
|
43
|
+
{ role: 'ADMIN', isActive: true, totpEnabled: state.viewerHasMfa },
|
|
44
|
+
{ role: 'EDITOR', isActive: true, totpEnabled: false },
|
|
45
|
+
],
|
|
46
|
+
findUnique: async () => ({ totpEnabled: state.viewerHasMfa }),
|
|
47
|
+
count: async () => 1,
|
|
48
|
+
},
|
|
49
|
+
session: {
|
|
50
|
+
findMany: async () => [],
|
|
51
|
+
count: async () => 2,
|
|
52
|
+
updateMany: async (args) => {
|
|
53
|
+
state.revokeAllArgs = args;
|
|
54
|
+
return { count: state.revokeAllResult };
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
auditLog: {
|
|
58
|
+
count: async () => 3,
|
|
59
|
+
create: async () => ({ id: 'log1' }),
|
|
60
|
+
},
|
|
61
|
+
$transaction: async (fn) => fn(tx),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function makeKey(scopes) {
|
|
65
|
+
const { key, keyHash, keyPrefix } = await generateApiKey({ prefix: 'act_sk', scopes });
|
|
66
|
+
return {
|
|
67
|
+
key,
|
|
68
|
+
record: {
|
|
69
|
+
id: 'apikey-1',
|
|
70
|
+
keyHash,
|
|
71
|
+
keyPrefix,
|
|
72
|
+
userId: 'user-1',
|
|
73
|
+
scopes,
|
|
74
|
+
ipRestrictions: null,
|
|
75
|
+
expiresAt: null,
|
|
76
|
+
lastUsedAt: null,
|
|
77
|
+
revokedAt: null,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function freshState() {
|
|
82
|
+
return {
|
|
83
|
+
settings: { siteTitle: 'Actuate Media' },
|
|
84
|
+
viewerHasMfa: false,
|
|
85
|
+
revokeAllResult: 2,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
delete globalThis.__actuateConfig;
|
|
90
|
+
process.env.CMS_SECRET = VALID_SECRET;
|
|
91
|
+
});
|
|
92
|
+
describe('GET /security', () => {
|
|
93
|
+
it('requires authentication', async () => {
|
|
94
|
+
const db = createMockDB(freshState());
|
|
95
|
+
initDB(db);
|
|
96
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
97
|
+
const res = await handler(new Request('https://example.com/api/cms/security'));
|
|
98
|
+
expect(res.status).toBe(401);
|
|
99
|
+
});
|
|
100
|
+
it('rejects non-admin API keys', async () => {
|
|
101
|
+
const { key, record } = await makeKey({ media: true });
|
|
102
|
+
const db = createMockDB(freshState(), record);
|
|
103
|
+
initDB(db);
|
|
104
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
105
|
+
const res = await handler(new Request('https://example.com/api/cms/security', {
|
|
106
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
107
|
+
}));
|
|
108
|
+
expect(res.status).toBe(403);
|
|
109
|
+
});
|
|
110
|
+
it('returns computed posture + summaries and never leaks secret values', async () => {
|
|
111
|
+
const { key, record } = await makeKey({ admin: true });
|
|
112
|
+
const db = createMockDB(freshState(), record);
|
|
113
|
+
initDB(db);
|
|
114
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
115
|
+
const res = await handler(new Request('https://example.com/api/cms/security', {
|
|
116
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
117
|
+
}));
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
const body = (await res.json());
|
|
120
|
+
expect(body.data.status.status).toBeDefined();
|
|
121
|
+
expect(Array.isArray(body.data.status.checks)).toBe(true);
|
|
122
|
+
expect(body.data.users.totalAdmins).toBe(1);
|
|
123
|
+
expect(body.data.secrets.cmsSecret).toBe('pass');
|
|
124
|
+
expect(body.data.activeSessions).toBe(2);
|
|
125
|
+
expect(body.data.failedLogins24h).toBe(3);
|
|
126
|
+
// The raw secret value must never appear anywhere in the payload.
|
|
127
|
+
expect(JSON.stringify(body)).not.toContain(VALID_SECRET);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('PUT /security', () => {
|
|
131
|
+
it('persists the enforced policy and reflects it on read-back', async () => {
|
|
132
|
+
const { key, record } = await makeKey({ admin: true });
|
|
133
|
+
const state = freshState();
|
|
134
|
+
const db = createMockDB(state, record);
|
|
135
|
+
initDB(db);
|
|
136
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
137
|
+
const res = await handler(new Request('https://example.com/api/cms/security', {
|
|
138
|
+
method: 'PUT',
|
|
139
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
mfa: { required: false, gracePeriodDays: 14 },
|
|
142
|
+
session: { maxConcurrentSessions: 3 },
|
|
143
|
+
}),
|
|
144
|
+
}));
|
|
145
|
+
expect(res.status).toBe(200);
|
|
146
|
+
const body = (await res.json());
|
|
147
|
+
expect(body.data.settings.session.maxConcurrentSessions).toBe(3);
|
|
148
|
+
expect(body.data.settings.mfa.gracePeriodDays).toBe(14);
|
|
149
|
+
// Persisted into the settings global without clobbering siblings.
|
|
150
|
+
expect(state.settings.security.session.maxConcurrentSessions).toBe(3);
|
|
151
|
+
expect(state.settings.siteTitle).toBe('Actuate Media');
|
|
152
|
+
});
|
|
153
|
+
it('blocks enabling MFA when the acting admin has no MFA (lockout-safe)', async () => {
|
|
154
|
+
const { key, record } = await makeKey({ admin: true });
|
|
155
|
+
const state = freshState();
|
|
156
|
+
state.viewerHasMfa = false;
|
|
157
|
+
const db = createMockDB(state, record);
|
|
158
|
+
initDB(db);
|
|
159
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
160
|
+
const res = await handler(new Request('https://example.com/api/cms/security', {
|
|
161
|
+
method: 'PUT',
|
|
162
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
mfa: { required: true, gracePeriodDays: 7 },
|
|
165
|
+
session: { maxConcurrentSessions: 5 },
|
|
166
|
+
}),
|
|
167
|
+
}));
|
|
168
|
+
expect(res.status).toBe(409);
|
|
169
|
+
const body = (await res.json());
|
|
170
|
+
expect(body.code).toBe('MFA_LOCKOUT_RISK');
|
|
171
|
+
// Nothing should have been persisted.
|
|
172
|
+
expect(state.settings.security).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
it('allows enabling MFA when the acting admin has MFA', async () => {
|
|
175
|
+
const { key, record } = await makeKey({ admin: true });
|
|
176
|
+
const state = freshState();
|
|
177
|
+
state.viewerHasMfa = true;
|
|
178
|
+
const db = createMockDB(state, record);
|
|
179
|
+
initDB(db);
|
|
180
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
181
|
+
const res = await handler(new Request('https://example.com/api/cms/security', {
|
|
182
|
+
method: 'PUT',
|
|
183
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
mfa: { required: true, gracePeriodDays: 7 },
|
|
186
|
+
session: { maxConcurrentSessions: 5 },
|
|
187
|
+
}),
|
|
188
|
+
}));
|
|
189
|
+
expect(res.status).toBe(200);
|
|
190
|
+
expect(state.settings.security.mfa.required).toBe(true);
|
|
191
|
+
expect(typeof state.settings.security.mfa.requiredAt).toBe('string');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe('POST /security/sessions/revoke-all', () => {
|
|
195
|
+
it("revokes the caller's other sessions and returns the count", async () => {
|
|
196
|
+
const { key, record } = await makeKey({ admin: true });
|
|
197
|
+
const state = freshState();
|
|
198
|
+
const db = createMockDB(state, record);
|
|
199
|
+
initDB(db);
|
|
200
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
201
|
+
const res = await handler(new Request('https://example.com/api/cms/security/sessions/revoke-all', {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
204
|
+
}));
|
|
205
|
+
expect(res.status).toBe(200);
|
|
206
|
+
const body = (await res.json());
|
|
207
|
+
expect(body.data.revoked).toBe(2);
|
|
208
|
+
// Excludes the current session id.
|
|
209
|
+
expect(state.revokeAllArgs?.where?.id?.not).toBe('apikey-1');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
//# sourceMappingURL=security-routes.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"security-routes.test.js","sourceRoot":"","sources":["../../../src/__tests__/api/security-routes.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp-login.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/api/totp-login.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { handleActuateAPI } from '../../api/index.js';
|
|
3
|
+
import { initDB } from '../../db.js';
|
|
4
|
+
import { encryptSecret, encryptStringArray } from '../../security/secret-storage.js';
|
|
5
|
+
import { createMfaPendingToken, computeRequestFingerprint } from '../../auth/mfa-pending.js';
|
|
6
|
+
import { generateTOTPSecret } from '../../auth/totp.js';
|
|
7
|
+
/**
|
|
8
|
+
* Contract test for the backup-code recovery path of POST /auth/totp/login.
|
|
9
|
+
*
|
|
10
|
+
* Backup codes are generated and shown at enrollment but, before this, were
|
|
11
|
+
* never consumed anywhere — a lost authenticator meant permanent lockout. This
|
|
12
|
+
* proves a valid code signs the user in, is single-use (consumed on success),
|
|
13
|
+
* and that an unknown code is rejected.
|
|
14
|
+
*/
|
|
15
|
+
const SECRET = 'a'.repeat(48);
|
|
16
|
+
const ENCRYPTION_KEY = 'b'.repeat(64); // 32 bytes hex
|
|
17
|
+
const BACKUP_CODES = ['aaaa1111', 'bbbb2222', 'cccc3333'];
|
|
18
|
+
async function createMockDB(state) {
|
|
19
|
+
const totpSecret = await encryptSecret(generateTOTPSecret());
|
|
20
|
+
return {
|
|
21
|
+
user: {
|
|
22
|
+
findUnique: async () => ({
|
|
23
|
+
id: 'user-1',
|
|
24
|
+
email: 'admin@example.com',
|
|
25
|
+
name: 'Admin',
|
|
26
|
+
role: 'ADMIN',
|
|
27
|
+
totpSecret,
|
|
28
|
+
totpEnabled: true,
|
|
29
|
+
backupCodes: await encryptStringArray(state.backupCodes),
|
|
30
|
+
isActive: true,
|
|
31
|
+
}),
|
|
32
|
+
update: async ({ data }) => {
|
|
33
|
+
// The handler re-encrypts the remaining codes; decrypt-by-count is
|
|
34
|
+
// enough for the assertion (we only check how many remain).
|
|
35
|
+
state.backupCodes = state.backupCodes.slice(0, data.backupCodes.length);
|
|
36
|
+
return { id: 'user-1' };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
document: {
|
|
40
|
+
findFirst: async () => ({ data: {} }),
|
|
41
|
+
},
|
|
42
|
+
session: {
|
|
43
|
+
findMany: async () => [],
|
|
44
|
+
create: async () => ({ id: 'sess-1' }),
|
|
45
|
+
updateMany: async () => ({ count: 0 }),
|
|
46
|
+
},
|
|
47
|
+
auditLog: {
|
|
48
|
+
create: async () => ({ id: 'log-1' }),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function makePendingToken(ip, userAgent) {
|
|
53
|
+
const fingerprint = await computeRequestFingerprint(ip, userAgent);
|
|
54
|
+
return createMfaPendingToken({ userId: 'user-1', fingerprint }, SECRET);
|
|
55
|
+
}
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
delete globalThis.__actuateConfig;
|
|
58
|
+
process.env.CMS_SECRET = SECRET;
|
|
59
|
+
process.env.CMS_ENCRYPTION_KEY = ENCRYPTION_KEY;
|
|
60
|
+
});
|
|
61
|
+
describe('POST /auth/totp/login — backup code recovery', () => {
|
|
62
|
+
it('signs in with a valid backup code and consumes it (single-use)', async () => {
|
|
63
|
+
const ip = '203.0.113.10';
|
|
64
|
+
const ua = 'vitest-agent';
|
|
65
|
+
const state = { backupCodes: [...BACKUP_CODES] };
|
|
66
|
+
const db = await createMockDB(state);
|
|
67
|
+
initDB(db);
|
|
68
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
69
|
+
const token = await makePendingToken(ip, ua);
|
|
70
|
+
const res = await handler(new Request('https://example.com/api/cms/auth/totp/login', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'x-real-ip': ip, 'user-agent': ua, 'content-type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({ mfaPendingToken: token, code: 'BBBB-2222' }),
|
|
74
|
+
}));
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
expect(res.headers.get('set-cookie')).toContain('actuate_session=');
|
|
77
|
+
// The used code was removed — one fewer remains.
|
|
78
|
+
expect(state.backupCodes).toHaveLength(BACKUP_CODES.length - 1);
|
|
79
|
+
});
|
|
80
|
+
it('rejects an unknown code', async () => {
|
|
81
|
+
const ip = '203.0.113.11';
|
|
82
|
+
const ua = 'vitest-agent';
|
|
83
|
+
const state = { backupCodes: [...BACKUP_CODES] };
|
|
84
|
+
const db = await createMockDB(state);
|
|
85
|
+
initDB(db);
|
|
86
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
87
|
+
const token = await makePendingToken(ip, ua);
|
|
88
|
+
const res = await handler(new Request('https://example.com/api/cms/auth/totp/login', {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'x-real-ip': ip, 'user-agent': ua, 'content-type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ mfaPendingToken: token, code: 'deadbeef' }),
|
|
92
|
+
}));
|
|
93
|
+
expect(res.status).toBe(401);
|
|
94
|
+
expect(state.backupCodes).toHaveLength(BACKUP_CODES.length);
|
|
95
|
+
});
|
|
96
|
+
it('rejects a stale pending token from a different device fingerprint', async () => {
|
|
97
|
+
const state = { backupCodes: [...BACKUP_CODES] };
|
|
98
|
+
const db = await createMockDB(state);
|
|
99
|
+
initDB(db);
|
|
100
|
+
const handler = handleActuateAPI({ prismaClient: db, config: { collections: {} } });
|
|
101
|
+
// Token minted for one device, redeemed from another (different UA).
|
|
102
|
+
const token = await makePendingToken('203.0.113.12', 'device-a');
|
|
103
|
+
const res = await handler(new Request('https://example.com/api/cms/auth/totp/login', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: {
|
|
106
|
+
'x-real-ip': '203.0.113.12',
|
|
107
|
+
'user-agent': 'device-b',
|
|
108
|
+
'content-type': 'application/json',
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({ mfaPendingToken: token, code: 'aaaa1111' }),
|
|
111
|
+
}));
|
|
112
|
+
expect(res.status).toBe(401);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
//# sourceMappingURL=totp-login.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp-login.test.js","sourceRoot":"","sources":["../../../src/__tests__/api/totp-login.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/auth/totp.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { generateBackupCodes, generateTOTPSecret, generateTOTPUri, verifyTOTP, normalizeBackupCode, findBackupCodeMatch, } from '../../auth/totp.js';
|
|
3
|
+
describe('totp helpers', () => {
|
|
4
|
+
it('generates a scannable otpauth URI for the given account', () => {
|
|
5
|
+
const secret = generateTOTPSecret();
|
|
6
|
+
const uri = generateTOTPUri(secret, 'admin@example.com');
|
|
7
|
+
expect(uri.startsWith('otpauth://totp/')).toBe(true);
|
|
8
|
+
expect(uri).toContain(`secret=${secret}`);
|
|
9
|
+
expect(uri).toContain('issuer=Actuate%20CMS');
|
|
10
|
+
expect(uri).toContain(encodeURIComponent('admin@example.com'));
|
|
11
|
+
});
|
|
12
|
+
it('verifies a TOTP code derived from the same secret', async () => {
|
|
13
|
+
// Derive the current code the same way the verifier does, then confirm it
|
|
14
|
+
// round-trips. Importing the private HOTP isn't exposed, so we lean on the
|
|
15
|
+
// window tolerance: a freshly generated secret should not validate a random
|
|
16
|
+
// 6-digit guess.
|
|
17
|
+
const secret = generateTOTPSecret();
|
|
18
|
+
expect(verifyTOTP('000000', secret)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('backup codes', () => {
|
|
22
|
+
it('generates the requested number of unique 8-char hex codes', () => {
|
|
23
|
+
const codes = generateBackupCodes(8);
|
|
24
|
+
expect(codes).toHaveLength(8);
|
|
25
|
+
expect(new Set(codes).size).toBe(8);
|
|
26
|
+
for (const code of codes) {
|
|
27
|
+
expect(code).toMatch(/^[0-9a-f]{8}$/);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
it('normalizes whitespace, dashes and case', () => {
|
|
31
|
+
expect(normalizeBackupCode('ABCD-1234')).toBe('abcd1234');
|
|
32
|
+
expect(normalizeBackupCode(' ab cd 12 34 ')).toBe('abcd1234');
|
|
33
|
+
expect(normalizeBackupCode('')).toBe('');
|
|
34
|
+
});
|
|
35
|
+
it('matches a stored code regardless of formatting and returns its index', () => {
|
|
36
|
+
const codes = ['aaaa1111', 'bbbb2222', 'cccc3333'];
|
|
37
|
+
expect(findBackupCodeMatch(codes, 'BBBB-2222')).toBe(1);
|
|
38
|
+
expect(findBackupCodeMatch(codes, ' cccc 3333 ')).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
it('returns -1 for a non-matching or empty submission', () => {
|
|
41
|
+
const codes = ['aaaa1111', 'bbbb2222'];
|
|
42
|
+
expect(findBackupCodeMatch(codes, 'deadbeef')).toBe(-1);
|
|
43
|
+
expect(findBackupCodeMatch(codes, '')).toBe(-1);
|
|
44
|
+
expect(findBackupCodeMatch([], 'aaaa1111')).toBe(-1);
|
|
45
|
+
});
|
|
46
|
+
it('does not match a code that only shares a prefix (length-aware compare)', () => {
|
|
47
|
+
const codes = ['aaaa1111'];
|
|
48
|
+
expect(findBackupCodeMatch(codes, 'aaaa111')).toBe(-1);
|
|
49
|
+
expect(findBackupCodeMatch(codes, 'aaaa11110')).toBe(-1);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
//# sourceMappingURL=totp.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp.test.js","sourceRoot":"","sources":["../../../src/__tests__/auth/totp.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"center.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/security/center.test.ts"],"names":[],"mappings":""}
|