@dax-crafta/auth 2.1.6 → 2.1.8

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/src/utils/mfa.js CHANGED
@@ -2,30 +2,39 @@
2
2
  const speakeasy = require('speakeasy');
3
3
  const qrcode = require('qrcode');
4
4
  const crypto = require('crypto');
5
+ const ApiError = require('./api-error');
5
6
 
6
7
  class MFAService {
7
8
  async generateSecret(label, issuer = 'App') {
8
- const secret = speakeasy.generateSecret({
9
- length: 32,
10
- name: label,
11
- issuer
12
- });
13
-
14
- const qrCodeUrl = await qrcode.toDataURL(secret.otpauth_url);
15
-
16
- return {
17
- secret: secret.base32,
18
- qrCode: qrCodeUrl
19
- };
9
+ try {
10
+ const secret = speakeasy.generateSecret({
11
+ length: 32,
12
+ name: label,
13
+ issuer
14
+ });
15
+
16
+ const qrCodeUrl = await qrcode.toDataURL(secret.otpauth_url);
17
+
18
+ return {
19
+ secret: secret.base32,
20
+ qrCode: qrCodeUrl
21
+ };
22
+ } catch (err) {
23
+ throw ApiError.internal('Failed to generate MFA secret');
24
+ }
20
25
  }
21
26
 
22
27
  verifyToken(token, secret) {
23
- return speakeasy.totp.verify({
24
- secret,
25
- encoding: 'base32',
26
- token,
27
- window: 1
28
- });
28
+ try {
29
+ return speakeasy.totp.verify({
30
+ secret,
31
+ encoding: 'base32',
32
+ token,
33
+ window: 1
34
+ });
35
+ } catch (err) {
36
+ return false;
37
+ }
29
38
  }
30
39
 
31
40
  // Generate hashed backup codes (safer)
@@ -1,5 +1,6 @@
1
1
  // packages/auth/src/utils/password-policy.js
2
2
  const zxcvbn = require('zxcvbn');
3
+ const { getPasswordStrength } = require('./password-strength');
3
4
 
4
5
  class PasswordPolicy {
5
6
  constructor(config = {}) {
@@ -37,10 +38,14 @@ class PasswordPolicy {
37
38
  if (strength.score < this.config.minStrength)
38
39
  errors.push('Password is too weak. Please choose a stronger password');
39
40
 
41
+ // Lightweight heuristic strength score (0-4), complementing zxcvbn
42
+ const heuristicScore = getPasswordStrength(password);
43
+
40
44
  return {
41
45
  isValid: errors.length === 0,
42
46
  errors,
43
- strength: strength.score
47
+ strength: strength.score,
48
+ strengthScore: heuristicScore
44
49
  };
45
50
  }
46
51
 
@@ -0,0 +1,68 @@
1
+ // packages/auth/src/utils/password-strength.js
2
+ /**
3
+ * Password strength scoring utility.
4
+ *
5
+ * Returns a strength score from 0 (very weak) to 4 (very strong) based on
6
+ * length, character variety, and common-pattern detection.
7
+ *
8
+ * This complements the zxcvbn-based strength check inside PasswordPolicy,
9
+ * providing a lightweight, dependency-free heuristic for quick feedback.
10
+ */
11
+ const COMMON_PASSWORDS = [
12
+ 'password', '123456', '12345678', '123456789', 'qwerty', 'abc123',
13
+ '111111', '123123', 'admin', 'letmein', 'welcome', 'monkey',
14
+ 'football', 'iloveyou', 'sunshine', 'princess', 'dragon', 'baseball'
15
+ ];
16
+
17
+ const COMMON_SEQUENCES = [
18
+ 'abcdefghijklmnopqrstuvwxyz',
19
+ '0123456789',
20
+ 'qwertyuiop',
21
+ 'asdfghjkl',
22
+ 'zxcvbnm'
23
+ ];
24
+
25
+ function hasCommonSequence(password, length) {
26
+ const lower = password.toLowerCase();
27
+ for (const seq of COMMON_SEQUENCES) {
28
+ for (let i = 0; i + length <= seq.length; i++) {
29
+ if (lower.includes(seq.slice(i, i + length))) return true;
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+
35
+ /**
36
+ * Calculate a strength score (0-4) for a password.
37
+ *
38
+ * @param {string} password - The password to evaluate.
39
+ * @returns {number} Score from 0 (very weak) to 4 (very strong).
40
+ */
41
+ function getPasswordStrength(password) {
42
+ if (!password || typeof password !== 'string') return 0;
43
+
44
+ const lower = password.toLowerCase();
45
+ const hasLower = /[a-z]/.test(password);
46
+ const hasUpper = /[A-Z]/.test(password);
47
+ const hasNumber = /\d/.test(password);
48
+ const hasSpecial = /[^a-zA-Z0-9]/.test(password);
49
+
50
+ let score = 0;
51
+
52
+ // Length scoring
53
+ if (password.length >= 8) score++;
54
+ if (password.length >= 12) score++;
55
+
56
+ // Complexity scoring
57
+ const varietyCount = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean).length;
58
+ if (varietyCount >= 3) score++;
59
+
60
+ // Penalize common patterns
61
+ if (COMMON_PASSWORDS.includes(lower)) return 0;
62
+ if (hasCommonSequence(password, 3)) score--;
63
+ if (/(.)\1{2,}/.test(password)) score--; // repeated chars like "aaa"
64
+
65
+ return Math.max(0, Math.min(4, score));
66
+ }
67
+
68
+ module.exports = { getPasswordStrength };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 01 — Register, login, refresh, logout, profile
3
+ */
4
+ const { describe, it, before, after } = require('./tiny-test');
5
+ const assert = require('assert');
6
+ const { buildApp, startApp, stopApp, req } = require('./helpers');
7
+
8
+ let server;
9
+
10
+ describe('01-register-login', () => {
11
+ before(async () => {
12
+ const app = buildApp();
13
+ server = await startApp();
14
+ });
15
+ after(async () => { await stopApp(); });
16
+
17
+ it('register creates a user', async () => {
18
+ const res = await req('POST', '/register', {
19
+ name: 'Test User',
20
+ email: 'alice@example.com',
21
+ password: 'Strong@Pass1',
22
+ });
23
+ assert.strictEqual(res.status, 201, `expected 201, got ${res.status} ${JSON.stringify(res.body)}`);
24
+ assert.strictEqual(res.body.success, true);
25
+ assert.strictEqual(res.body.message, 'Registration successful');
26
+ });
27
+
28
+ it('register rejects duplicate email', async () => {
29
+ const res = await req('POST', '/register', {
30
+ name: 'Dup User',
31
+ email: 'alice@example.com',
32
+ password: 'Strong@Pass1',
33
+ });
34
+ assert.strictEqual(res.status, 409, `expected 409, got ${res.status}`);
35
+ assert.strictEqual(res.body.code, 'AUTH_CONFLICT');
36
+ });
37
+
38
+ it('register rejects weak password', async () => {
39
+ const res = await req('POST', '/register', {
40
+ name: 'Weak User',
41
+ email: 'weak@example.com',
42
+ password: 'abc',
43
+ });
44
+ assert.strictEqual(res.status, 400, `expected 400, got ${res.status}`);
45
+ assert.ok(res.body.error || res.body.code, 'should have error message');
46
+ });
47
+
48
+ it('login succeeds with valid creds', async () => {
49
+ const res = await req('POST', '/login', {
50
+ email: 'alice@example.com',
51
+ password: 'Strong@Pass1',
52
+ });
53
+ assert.strictEqual(res.status, 200, `expected 200, got ${res.status}`);
54
+ assert.ok(res.body.accessToken);
55
+ assert.ok(res.body.refreshToken);
56
+ });
57
+
58
+ it('login rejects wrong password', async () => {
59
+ const res = await req('POST', '/login', {
60
+ email: 'alice@example.com',
61
+ password: 'Wrong@Pass1',
62
+ });
63
+ assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
64
+ assert.strictEqual(res.body.code, 'AUTH_INVALID_CREDENTIALS');
65
+ });
66
+
67
+ it('login rejects unknown email', async () => {
68
+ const res = await req('POST', '/login', {
69
+ email: 'nobody@example.com',
70
+ password: 'Strong@Pass1',
71
+ });
72
+ assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
73
+ assert.strictEqual(res.body.code, 'AUTH_INVALID_CREDENTIALS');
74
+ });
75
+
76
+ it('refresh token rotates and old token is invalidated', async () => {
77
+ const login = await req('POST', '/login', {
78
+ email: 'alice@example.com',
79
+ password: 'Strong@Pass1',
80
+ });
81
+ const oldRefresh = login.body.refreshToken;
82
+
83
+ const refresh = await req('POST', '/refresh-token', { refreshToken: oldRefresh });
84
+ assert.strictEqual(refresh.status, 200, `expected 200, got ${refresh.status}`);
85
+ assert.ok(refresh.body.accessToken);
86
+ assert.notStrictEqual(refresh.body.refreshToken, oldRefresh, 'refresh token should rotate');
87
+
88
+ // Old token must be rejected
89
+ const reuse = await req('POST', '/refresh-token', { refreshToken: oldRefresh });
90
+ assert.strictEqual(reuse.status, 401, `old refresh token should be rejected, got ${reuse.status}`);
91
+ assert.strictEqual(reuse.body.code, 'AUTH_INVALID_TOKEN');
92
+ });
93
+
94
+ it('protected profile returns user data with valid token', async () => {
95
+ const login = await req('POST', '/login', {
96
+ email: 'alice@example.com',
97
+ password: 'Strong@Pass1',
98
+ });
99
+ const res = await req('PUT', '/profile', { name: 'Test User Updated' }, {
100
+ Authorization: `Bearer ${login.body.accessToken}`,
101
+ });
102
+ assert.strictEqual(res.status, 200, `expected 200, got ${res.status} ${JSON.stringify(res.body)}`);
103
+ assert.strictEqual(res.body.user.email, 'alice@example.com');
104
+ });
105
+
106
+ it('protected route rejects missing token', async () => {
107
+ const res = await req('PUT', '/profile', { name: 'X' });
108
+ assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
109
+ assert.strictEqual(res.body.code, 'AUTH_UNAUTHORIZED');
110
+ });
111
+
112
+ it('protected route rejects invalid token', async () => {
113
+ const res = await req('PUT', '/profile', { name: 'X' }, {
114
+ Authorization: 'Bearer not-a-real-token',
115
+ });
116
+ assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
117
+ assert.strictEqual(res.body.code, 'AUTH_INVALID_TOKEN');
118
+ });
119
+
120
+ it('logout revokes refresh token', async () => {
121
+ const login = await req('POST', '/login', {
122
+ email: 'alice@example.com',
123
+ password: 'Strong@Pass1',
124
+ });
125
+ const logout = await req('POST', '/logout', { refreshToken: login.body.refreshToken }, {
126
+ Authorization: `Bearer ${login.body.accessToken}`,
127
+ });
128
+ assert.strictEqual(logout.status, 200, `expected 200, got ${logout.status} ${JSON.stringify(logout.body)}`);
129
+
130
+ // Refresh token should now be dead
131
+ const after = await req('POST', '/refresh-token', { refreshToken: login.body.refreshToken });
132
+ assert.strictEqual(after.status, 401, `expected 401 after logout, got ${after.status}`);
133
+ });
134
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 02 — Password reset flow + forgot password
3
+ */
4
+ const { describe, it, before, after } = require('./tiny-test');
5
+ const assert = require('assert');
6
+ const { buildApp, startApp, stopApp, req } = require('./helpers');
7
+
8
+ let server;
9
+
10
+ describe('02-password-reset', () => {
11
+ before(async () => {
12
+ const app = buildApp();
13
+ server = await startApp();
14
+ await req('POST', '/register', {
15
+ name: 'Bob Reset',
16
+ email: 'bob@example.com',
17
+ password: 'Strong@Pass1',
18
+ });
19
+ });
20
+ after(async () => { await stopApp(); });
21
+
22
+ it('forgot-password returns 200 even for unknown email', async () => {
23
+ const res = await req('POST', '/forgot-password', { email: 'ghost@example.com' });
24
+ assert.strictEqual(res.status, 200, `expected 200, got ${res.status}`);
25
+ assert.strictEqual(res.body.success, true);
26
+ });
27
+
28
+ it('reset-password rejects invalid token', async () => {
29
+ const res = await req('POST', '/reset-password', {
30
+ token: 'invalid-token',
31
+ newPassword: 'New@Pass1',
32
+ });
33
+ assert.strictEqual(res.status, 400, `expected 400, got ${res.status}`);
34
+ assert.ok(res.body.code, 'should have error code');
35
+ });
36
+
37
+ it('reset-password rejects weak new password', async () => {
38
+ const res = await req('POST', '/reset-password', {
39
+ token: 'some-token',
40
+ newPassword: 'weak',
41
+ });
42
+ assert.strictEqual(res.status, 400, `expected 400, got ${res.status}`);
43
+ assert.ok(res.body.error || res.body.code, 'should have error message');
44
+ });
45
+
46
+ it('reset-password rejects missing token', async () => {
47
+ const res = await req('POST', '/reset-password', { newPassword: 'New@Pass1' });
48
+ assert.strictEqual(res.status, 400, `expected 400, got ${res.status}`);
49
+ });
50
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 03 — 2FA / MFA flow
3
+ */
4
+ const { describe, it, before, after } = require('./tiny-test');
5
+ const assert = require('assert');
6
+ const mongoose = require('mongoose');
7
+ const { buildApp, startApp, stopApp, req } = require('./helpers');
8
+
9
+ let server;
10
+
11
+ describe('03-2fa', () => {
12
+ before(async () => {
13
+ const app = buildApp();
14
+ server = await startApp();
15
+ // Register user
16
+ await req('POST', '/register', {
17
+ name: 'Two FA User',
18
+ email: 'two@example.com',
19
+ password: 'Strong@Pass1',
20
+ });
21
+ // Enable 2FA directly by setting twoFactorSecret and twoFactorEnabled
22
+ require('../src/models/user');
23
+ const User = mongoose.model('User');
24
+ const crypto = require('crypto');
25
+ const secret = crypto.randomBytes(20).toString('hex');
26
+ await User.findOneAndUpdate(
27
+ { email: 'two@example.com' },
28
+ { twoFactorSecret: secret, twoFactorEnabled: true }
29
+ );
30
+ });
31
+ after(async () => { await stopApp(); });
32
+
33
+ it('2fa verify rejects missing code', async () => {
34
+ require('../src/models/user');
35
+ const User = mongoose.model('User');
36
+ const user = await User.findOne({ email: 'two@example.com' });
37
+ const res = await req('POST', '/2fa', { userId: user._id });
38
+ assert.strictEqual(res.status, 400, `expected 400, got ${res.status}`);
39
+ });
40
+
41
+ it('2fa verify rejects unknown user', async () => {
42
+ const res = await req('POST', '/2fa', { userId: '000000000000000000000000', code: '123456' });
43
+ assert.strictEqual(res.status, 404, `expected 404, got ${res.status}`);
44
+ });
45
+
46
+ it('2fa verify rejects invalid code', async () => {
47
+ require('../src/models/user');
48
+ const User = mongoose.model('User');
49
+ const user = await User.findOne({ email: 'two@example.com' });
50
+ const res = await req('POST', '/2fa', { userId: user._id, code: '000000' });
51
+ assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
52
+ assert.strictEqual(res.body.code, 'AUTH_2FA_FAILED');
53
+ });
54
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 04 — RBAC: roles + permissions
3
+ */
4
+ const { describe, it, before, after } = require('./tiny-test');
5
+ const assert = require('assert');
6
+ const mongoose = require('mongoose');
7
+ const { buildApp, startApp, stopApp, req } = require('./helpers');
8
+
9
+ let server;
10
+
11
+ describe('04-rbac', () => {
12
+ before(async () => {
13
+ const app = buildApp();
14
+ server = await startApp();
15
+ await req('POST', '/register', {
16
+ name: 'Admin User',
17
+ email: 'admin@example.com',
18
+ password: 'Strong@Pass1',
19
+ });
20
+ require('../src/models/user');
21
+ const User = mongoose.model('User');
22
+ await User.findOneAndUpdate({ email: 'admin@example.com' }, { role: 'admin' });
23
+ });
24
+ after(async () => { await stopApp(); });
25
+
26
+ it('create role succeeds as admin', async () => {
27
+ const login = await req('POST', '/login', {
28
+ email: 'admin@example.com',
29
+ password: 'Strong@Pass1',
30
+ });
31
+ const res = await req('POST', '/roles', {
32
+ name: 'editor',
33
+ permissions: [{ resource: 'posts', actions: ['create', 'read'] }],
34
+ }, { Authorization: `Bearer ${login.body.accessToken}` });
35
+ assert.strictEqual(res.status, 201, `expected 201, got ${res.status} ${JSON.stringify(res.body)}`);
36
+ assert.strictEqual(res.body.role.name, 'editor');
37
+ });
38
+
39
+ it('reject duplicate role name', async () => {
40
+ const login = await req('POST', '/login', {
41
+ email: 'admin@example.com',
42
+ password: 'Strong@Pass1',
43
+ });
44
+ const res = await req('POST', '/roles', {
45
+ name: 'editor',
46
+ permissions: [],
47
+ }, { Authorization: `Bearer ${login.body.accessToken}` });
48
+ assert.strictEqual(res.status, 409, `expected 409, got ${res.status}`);
49
+ assert.strictEqual(res.body.code, 'AUTH_CONFLICT');
50
+ });
51
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 05 — Audit logging + webhooks
3
+ */
4
+ const { describe, it, before, after } = require('./tiny-test');
5
+ const assert = require('assert');
6
+ const http = require('http');
7
+ const mongoose = require('mongoose');
8
+ const { buildApp, startApp, stopApp, req } = require('./helpers');
9
+
10
+ let server;
11
+ let webhookServer;
12
+ let receivedWebhooks = [];
13
+
14
+ describe('05-audit-webhook', () => {
15
+ before(async () => {
16
+ webhookServer = http.createServer((req, res) => {
17
+ let body = '';
18
+ req.on('data', (c) => (body += c));
19
+ req.on('end', () => {
20
+ try {
21
+ receivedWebhooks.push({
22
+ event: req.headers['x-crafta-event'],
23
+ signature: req.headers['x-crafta-signature'],
24
+ body: JSON.parse(body),
25
+ });
26
+ } catch {}
27
+ res.writeHead(200);
28
+ res.end('ok');
29
+ });
30
+ });
31
+ await new Promise((r) => webhookServer.listen(0, r));
32
+ const port = webhookServer.address().port;
33
+
34
+ const app = buildApp({
35
+ webhooks: {
36
+ url: `http://127.0.0.1:${port}`,
37
+ secret: 'test-secret',
38
+ events: ['user.registered', 'user.login', 'user.logout'],
39
+ },
40
+ features: { auditLogs: true, webhooks: true },
41
+ });
42
+ server = await startApp();
43
+
44
+ await req('POST', '/register', {
45
+ name: 'Audit User',
46
+ email: 'audit@example.com',
47
+ password: 'Strong@Pass1',
48
+ });
49
+ });
50
+ after(async () => {
51
+ await stopApp();
52
+ await new Promise((r) => webhookServer.close(r));
53
+ });
54
+
55
+ it('webhook fires on register with HMAC signature', async () => {
56
+ const found = receivedWebhooks.find((w) => w.event === 'user.registered');
57
+ assert.ok(found, `register webhook should fire. Got: ${JSON.stringify(receivedWebhooks)}`);
58
+ assert.ok(found.signature, 'webhook should be signed');
59
+ assert.strictEqual(found.body.data.email, 'audit@example.com');
60
+ });
61
+
62
+ it('audit log is written to MongoDB', async () => {
63
+ require('../src/models/audit-log');
64
+ const AuditLog = mongoose.model('AuditLog');
65
+ const count = await AuditLog.countDocuments({ action: 'register' });
66
+ assert.ok(count > 0, 'audit log entry for register should exist');
67
+ });
68
+
69
+ it('audit log has required enum actions', async () => {
70
+ require('../src/models/audit-log');
71
+ const AuditLog = mongoose.model('AuditLog');
72
+ const actions = AuditLog.schema.path('action').enumValues;
73
+ const required = ['login', 'logout', 'register', 'email_verify', '2fa_verify', 'refresh_token', 'social_login', 'backup_codes_generated', 'account_locked', 'password_reset', 'password_change', 'profile_update', 'role_change', '2fa_enabled', '2fa_disabled', 'password_reset_request'];
74
+ for (const a of required) {
75
+ assert.ok(actions.includes(a), `missing audit action: ${a}`);
76
+ }
77
+ });
78
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 06 — Session management
3
+ */
4
+ const { describe, it, before, after } = require('./tiny-test');
5
+ const assert = require('assert');
6
+ const { buildApp, startApp, stopApp, req } = require('./helpers');
7
+
8
+ let server;
9
+
10
+ describe('06-sessions', () => {
11
+ before(async () => {
12
+ const app = buildApp({ session: { maxSessions: 3, sessionExpiryMs: 30 * 24 * 60 * 60 * 1000 } });
13
+ server = await startApp();
14
+ await req('POST', '/register', {
15
+ name: 'Session User',
16
+ email: 'session@example.com',
17
+ password: 'Strong@Pass1',
18
+ });
19
+ });
20
+ after(async () => { await stopApp(); });
21
+
22
+ it('logout-all invalidates all sessions', async () => {
23
+ const l1 = await req('POST', '/login', { email: 'session@example.com', password: 'Strong@Pass1' });
24
+ const l2 = await req('POST', '/login', { email: 'session@example.com', password: 'Strong@Pass1' });
25
+
26
+ const res = await req('POST', '/logout-all', {}, {
27
+ Authorization: `Bearer ${l1.body.accessToken}`,
28
+ });
29
+ assert.strictEqual(res.status, 200, `expected 200, got ${res.status} ${JSON.stringify(res.body)}`);
30
+
31
+ const p1 = await req('PUT', '/profile', { name: 'X' }, { Authorization: `Bearer ${l1.body.accessToken}` });
32
+ const p2 = await req('PUT', '/profile', { name: 'X' }, { Authorization: `Bearer ${l2.body.accessToken}` });
33
+ assert.strictEqual(p1.status, 401, 'session 1 token should be dead');
34
+ assert.strictEqual(p2.status, 401, 'session 2 token should be dead');
35
+ });
36
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 07 — Error normalization + standardized error codes
3
+ */
4
+ const { describe, it } = require('./tiny-test');
5
+ const assert = require('assert');
6
+ const ApiError = require('../src/utils/api-error');
7
+
8
+ describe('07-error-normalization', () => {
9
+ it('has all 19 factory methods', () => {
10
+ const methods = [
11
+ 'unauthorized', 'forbidden', 'notFound', 'validation', 'badRequest',
12
+ 'conflict', 'tooManyRequests', 'accountLocked', 'invalidCredentials',
13
+ 'invalidToken', 'expiredToken', 'emailNotVerified', 'twoFactorRequired',
14
+ 'twoFactorFailed', 'passwordPolicy', 'passwordReuse', 'smtpDisabled',
15
+ 'socialAuthFailed', 'internal'
16
+ ];
17
+ for (const m of methods) {
18
+ assert.strictEqual(typeof ApiError[m], 'function', `ApiError.${m} should be a function`);
19
+ const err = ApiError[m]();
20
+ assert.ok(err instanceof ApiError, `${m} should return ApiError instance`);
21
+ assert.ok(err.code, `${m} should set a code`);
22
+ assert.ok(err.status, `${m} should set a status`);
23
+ }
24
+ });
25
+
26
+ it('normalize converts Mongoose ValidationError', () => {
27
+ const err = { name: 'ValidationError', message: 'bad', errors: { email: { message: 'x' } } };
28
+ const normalized = ApiError.normalize(err);
29
+ assert.strictEqual(normalized.code, 'AUTH_VALIDATION');
30
+ assert.strictEqual(normalized.status, 400);
31
+ });
32
+
33
+ it('normalize converts JWT errors', () => {
34
+ const expired = ApiError.normalize({ name: 'TokenExpiredError', message: 'expired' });
35
+ assert.strictEqual(expired.code, 'AUTH_EXPIRED_TOKEN');
36
+ const invalid = ApiError.normalize({ name: 'JsonWebTokenError', message: 'bad' });
37
+ assert.strictEqual(invalid.code, 'AUTH_INVALID_TOKEN');
38
+ });
39
+
40
+ it('normalize converts duplicate key 11000', () => {
41
+ const dup = ApiError.normalize({ code: 11000, message: 'dup', keyValue: { email: 'a@b.com' } });
42
+ assert.strictEqual(dup.code, 'AUTH_CONFLICT');
43
+ });
44
+
45
+ it('normalize converts generic Error', () => {
46
+ const gen = ApiError.normalize(new Error('boom'));
47
+ assert.strictEqual(gen.code, 'AUTH_INTERNAL');
48
+ assert.strictEqual(gen.status, 500);
49
+ });
50
+
51
+ it('normalize passes through ApiError unchanged', () => {
52
+ const orig = ApiError.forbidden('nope');
53
+ const same = ApiError.normalize(orig);
54
+ assert.strictEqual(same, orig);
55
+ });
56
+
57
+ it('error response shape has success:false + code + error', () => {
58
+ const err = ApiError.unauthorized('need token');
59
+ const json = { success: false, code: err.code, error: err.message };
60
+ assert.strictEqual(json.success, false);
61
+ assert.strictEqual(json.code, 'AUTH_UNAUTHORIZED');
62
+ assert.strictEqual(json.error, 'need token');
63
+ });
64
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 08 — CORS + health endpoint
3
+ */
4
+ const { describe, it, before, after } = require('./tiny-test');
5
+ const assert = require('assert');
6
+ const { buildApp, startApp, stopApp, req } = require('./helpers');
7
+
8
+ let server;
9
+
10
+ describe('08-cors-health', () => {
11
+ before(async () => {
12
+ const app = buildApp();
13
+ server = await startApp();
14
+ });
15
+ after(async () => { await stopApp(); });
16
+
17
+ it('health endpoint returns ok + db status', async () => {
18
+ const res = await req('GET', '/auth/health');
19
+ assert.strictEqual(res.status, 200, `expected 200, got ${res.status}`);
20
+ assert.strictEqual(res.body.status, 'ok');
21
+ assert.strictEqual(res.body.db, 'connected');
22
+ assert.ok(res.body.version, 'should report version');
23
+ assert.strictEqual(typeof res.body.features, 'object');
24
+ });
25
+
26
+ it('CORS preflight returns 204 with origin header', async () => {
27
+ const url = `http://127.0.0.1:${server.address().port}/login`;
28
+ const res = await fetch(url, {
29
+ method: 'OPTIONS',
30
+ headers: {
31
+ Origin: 'https://example.com',
32
+ 'Access-Control-Request-Method': 'POST',
33
+ },
34
+ });
35
+ assert.strictEqual(res.status, 204, `expected 204, got ${res.status}`);
36
+ const origin = res.headers.get('access-control-allow-origin');
37
+ assert.ok(origin, 'should have allow-origin header');
38
+ });
39
+
40
+ it('CORS actual request includes allow-origin header', async () => {
41
+ const res = await req('POST', '/login', { email: 'x', password: 'y' }, { Origin: 'https://example.com' });
42
+ // Login with invalid creds returns 401 (or 400 if validation fails)
43
+ assert.ok(res.status === 400 || res.status === 401, `expected 400/401, got ${res.status}`);
44
+ });
45
+ });