@dax-crafta/auth 2.1.7 → 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.
@@ -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
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 09 — Passwordless magic link
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('09-passwordless', () => {
11
+ before(async () => {
12
+ const app = buildApp({
13
+ features: { passwordless: true, emailVerification: false, loginAlerts: false },
14
+ smtp: {
15
+ host: 'smtp.example.com',
16
+ port: 587,
17
+ auth: { user: 'test', pass: 'test' },
18
+ from: 'noreply@example.com',
19
+ },
20
+ });
21
+ server = await startApp();
22
+ await req('POST', '/register', {
23
+ name: 'Magic User',
24
+ email: 'magic@example.com',
25
+ password: 'Strong@Pass1',
26
+ });
27
+ });
28
+ after(async () => { await stopApp(); });
29
+
30
+ it('magic link request returns 200 (no oracle)', async () => {
31
+ const res = await req('POST', '/auth/magic-link', { email: 'ghost@example.com' });
32
+ assert.strictEqual(res.status, 200, `expected 200, got ${res.status}`);
33
+ });
34
+
35
+ it('magic link verify rejects invalid token', async () => {
36
+ const res = await req('GET', '/auth/magic-link/verify?token=invalid');
37
+ assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
38
+ assert.strictEqual(res.body.code, 'AUTH_INVALID_TOKEN');
39
+ });
40
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 10 — Webhook service: signing, event filtering, delivery
3
+ */
4
+ const { describe, it } = require('./tiny-test');
5
+ const assert = require('assert');
6
+ const WebhookService = require('../src/services/webhook.service');
7
+
8
+ describe('10-webhook-signing', () => {
9
+ it('sign returns HMAC-SHA256 hex when secret is set', () => {
10
+ const svc = new WebhookService({ webhooks: { url: 'http://example.com', secret: 's3cr3t' } });
11
+ const sig = svc.sign('hello');
12
+ assert.strictEqual(typeof sig, 'string');
13
+ assert.strictEqual(sig.length, 64, 'HMAC-SHA256 hex digest is 64 chars');
14
+ });
15
+
16
+ it('sign returns null when no secret', () => {
17
+ const svc = new WebhookService({ webhooks: { url: 'http://example.com' } });
18
+ assert.strictEqual(svc.sign('hello'), null);
19
+ });
20
+
21
+ it('emit is a no-op when webhooks disabled', async () => {
22
+ const svc = new WebhookService({});
23
+ const result = await svc.emit('login', { userId: 'u1' });
24
+ assert.strictEqual(result.delivered, false);
25
+ assert.strictEqual(result.reason, 'webhooks_disabled');
26
+ });
27
+
28
+ it('emit filters events not subscribed', async () => {
29
+ const svc = new WebhookService({
30
+ webhooks: { url: 'http://example.com', secret: 's3cr3t', events: ['login'] },
31
+ });
32
+ const result = await svc.emit('logout', { userId: 'u1' });
33
+ assert.strictEqual(result.delivered, false);
34
+ assert.strictEqual(result.reason, 'event_not_subscribed');
35
+ assert.strictEqual(result.event, 'logout');
36
+ });
37
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Test helpers — shared setup for the live integration suite.
3
+ * Starts an Express server with @dax-crafta/auth against a real MongoDB.
4
+ */
5
+ const mongoose = require('mongoose');
6
+ const express = require('express');
7
+ const cookieParser = require('cookie-parser');
8
+ const { auth, ApiError } = require('../src/index');
9
+
10
+ const MONGO_URL = process.env.MONGO_URL || 'mongodb://127.0.0.1:27017/crafta-auth-test';
11
+
12
+ let server = null;
13
+ let app = null;
14
+
15
+ async function connectMongo() {
16
+ if (mongoose.connection.readyState === 0) {
17
+ await mongoose.connect(MONGO_URL);
18
+ }
19
+ // Wipe test collections for a clean slate
20
+ const cols = Object.keys(mongoose.connection.collections);
21
+ for (const name of cols) {
22
+ await mongoose.connection.collections[name].deleteMany({});
23
+ }
24
+ }
25
+
26
+ async function disconnectMongo() {
27
+ if (mongoose.connection.readyState !== 0) {
28
+ await mongoose.disconnect();
29
+ }
30
+ }
31
+
32
+ function buildApp(overrides = {}) {
33
+ app = express();
34
+ app.use(express.json());
35
+ app.use(cookieParser());
36
+
37
+ auth({
38
+ mongoUrl: MONGO_URL,
39
+ env: { JWT_SECRET: 'test-secret-256-bits-long-key' },
40
+ features: {
41
+ emailVerification: false,
42
+ loginAlerts: false,
43
+ csrf: false,
44
+ rateLimit: false,
45
+ auditLogs: true,
46
+ twoFactor: true,
47
+ passwordless: false,
48
+ webhooks: true,
49
+ },
50
+ session: { maxSessions: 3, sessionExpiryMs: 30 * 24 * 60 * 60 * 1000 },
51
+ webhooks: {
52
+ secret: 'test-webhook-secret',
53
+ events: ['user.registered', 'user.login', 'user.logout'],
54
+ },
55
+ ...overrides,
56
+ })(app);
57
+
58
+ // Ensure MongoDB connection is established before returning, and models are registered.
59
+ // auth() connects async; without this the first test hits a duplicate-key
60
+ // error because the connection isn't ready when the request fires.
61
+ global.__resetDb = async () => {
62
+ if (mongoose.connection.readyState === 0) {
63
+ await mongoose.connect(MONGO_URL);
64
+ }
65
+ // Ensure models are registered by requiring user and audit-log models
66
+ try { require('../src/models/user'); } catch (e) {}
67
+ try { require('../src/models/audit-log'); } catch (e) {}
68
+
69
+ const cols = Object.keys(mongoose.connection.collections);
70
+ for (const name of cols) {
71
+ try {
72
+ await mongoose.connection.collections[name].drop();
73
+ } catch (e) {
74
+ // Collection may not exist yet — ignore
75
+ }
76
+ }
77
+ };
78
+
79
+ return app;
80
+ }
81
+
82
+ function startApp(port = 0) {
83
+ return new Promise((resolve) => {
84
+ server = app.listen(port, () => {
85
+ resolve(server);
86
+ });
87
+ });
88
+ }
89
+
90
+ async function stopApp() {
91
+ if (server) {
92
+ await new Promise((r) => server.close(r));
93
+ server = null;
94
+ }
95
+ }
96
+
97
+ // ── HTTP helpers ──
98
+ const BASE = () => `http://127.0.0.1:${server.address().port}`;
99
+
100
+ async function req(method, path, body, headers = {}) {
101
+ const url = `${BASE()}${path}`;
102
+ const opts = {
103
+ method,
104
+ headers: { 'Content-Type': 'application/json', ...headers },
105
+ };
106
+ // GET/HEAD requests cannot have a body per fetch spec
107
+ if (body !== undefined && !['GET', 'HEAD'].includes(method)) {
108
+ opts.body = JSON.stringify(body);
109
+ }
110
+ const res = await fetch(url, opts);
111
+ let json = null;
112
+ let text = null;
113
+ const clone = res.clone();
114
+ try {
115
+ json = await res.json();
116
+ } catch {
117
+ text = await clone.text();
118
+ }
119
+ return { status: res.status, body: json, text };
120
+ }
121
+
122
+ module.exports = {
123
+ connectMongo,
124
+ disconnectMongo,
125
+ buildApp,
126
+ startApp,
127
+ stopApp,
128
+ req,
129
+ ApiError,
130
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Test runner — runs all test/*.test.js files sequentially.
3
+ * Each test file uses the tiny-test framework (describe/it/before/after).
4
+ *
5
+ * IMPORTANT: helpers and tiny-test are NEVER cleared from cache between
6
+ * test files, because helpers registers Mongoose models via auth().
7
+ * Only the test file itself is cleared.
8
+ */
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+ const mongoose = require('mongoose');
12
+
13
+ const testDir = __dirname;
14
+ const files = fs
15
+ .readdirSync(testDir)
16
+ .filter((f) => f.endsWith('.test.js') && !f.startsWith('run-') && !f.startsWith('tiny-'))
17
+ .sort();
18
+
19
+ const MONGO_URL = process.env.MONGO_URL || 'mongodb://127.0.0.1:27017/crafta-auth-test';
20
+
21
+ // Register Mongoose models eagerly so they exist before any test file runs.
22
+ // auth() normally does this lazily, but the DB reset happens BEFORE auth()
23
+ // is called in each test file's before() hook.
24
+ require('./helpers'); // triggers require chain that registers models
25
+ require('../src/models/user');
26
+ require('../src/models/audit-log');
27
+
28
+ async function resetMongoose() {
29
+ // Connect once and keep the connection — disconnecting invalidates
30
+ // the mongoose models registered by auth().
31
+ if (mongoose.connection.readyState === 0) {
32
+ await mongoose.connect(MONGO_URL);
33
+ }
34
+ // Drop every collection known to the MongoDB server.
35
+ // We use db.listCollections() rather than mongoose.connection.collections
36
+ // because the latter only tracks collections touched through Mongoose models,
37
+ // which is empty until auth() runs (after this reset).
38
+ const cols = await mongoose.connection.db.listCollections().toArray();
39
+ for (const col of cols) {
40
+ try {
41
+ await mongoose.connection.db.dropCollection(col.name);
42
+ } catch (e) {
43
+ // Collection may not exist yet — ignore
44
+ }
45
+ }
46
+ }
47
+
48
+ async function run() {
49
+ let totalPassed = 0;
50
+ let totalFailed = 0;
51
+ const failures = [];
52
+
53
+ for (const file of files) {
54
+ const filePath = path.join(testDir, file);
55
+ console.log(`\n=== ${file} ===`);
56
+
57
+ // Only clear the test file's own cache — keep helpers and tiny-test cached
58
+ // so Mongoose models registered by auth() stay registered.
59
+ const resolved = require.resolve(filePath);
60
+ delete require.cache[resolved];
61
+
62
+ try {
63
+ await resetMongoose();
64
+ require(filePath);
65
+ const tinyTest = require('./tiny-test');
66
+ await tinyTest.runPending();
67
+
68
+ const stats = tinyTest.getStats();
69
+ totalPassed += stats.passed;
70
+ totalFailed += stats.failed;
71
+ failures.push(...stats.failures);
72
+ } catch (err) {
73
+ console.error(`[FAIL] ${file}: ${err.message}`);
74
+ if (err.stack) console.error(err.stack.split('\n').slice(1, 6).join('\n'));
75
+ totalFailed += 1;
76
+ failures.push({ name: file, err });
77
+ }
78
+ }
79
+
80
+ console.log(`\nSummary: ${totalPassed} passed, ${totalFailed} failed`);
81
+ if (failures.length > 0) {
82
+ console.log('\nFailures:');
83
+ failures.forEach((f) => console.log(` - ${f.name}: ${f.err.message}`));
84
+ }
85
+
86
+ if (totalFailed > 0) {
87
+ process.exitCode = 1;
88
+ }
89
+ }
90
+
91
+ run().catch((err) => {
92
+ console.error('Fatal error:', err);
93
+ process.exit(1);
94
+ });