Includes MFA-capable authentication, session and CSRF security, password recovery, listing lifecycle and ownership rules, inventory image metadata, local ImageStorage, migrations, tests, and implementation status documentation. Verification: npm test (62 passing) and integration suite (14 passing).
201 lines
6.6 KiB
JavaScript
201 lines
6.6 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import { hashPassword, verifyPassword } from '../../src/modules/auth/domain/password.js';
|
|
import { createAuthenticationService } from '../../src/modules/auth/services/authentication-service.js';
|
|
|
|
test('verifies a password hash without accepting a different password', async () => {
|
|
const passwordHash = await hashPassword('correct-password');
|
|
|
|
assert.equal(await verifyPassword('correct-password', passwordHash), true);
|
|
assert.equal(await verifyPassword('incorrect-password', passwordHash), false);
|
|
});
|
|
|
|
test('creates only a hashed session token after valid credentials', async () => {
|
|
let createdSession;
|
|
const auditEvents = [];
|
|
const service = createAuthenticationService({
|
|
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
|
passwordVerifier: async (password) => password === 'correct-password',
|
|
sessionRepository: {
|
|
async create(session) {
|
|
createdSession = session;
|
|
},
|
|
},
|
|
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
|
userRepository: {
|
|
async findAuthenticationUserByEmail() {
|
|
return {
|
|
id: 'user-id',
|
|
sellerId: 'seller-id',
|
|
role: 'ADMIN',
|
|
passwordHash: 'stored-hash',
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await service.login({
|
|
email: 'admin@example.test',
|
|
password: 'correct-password',
|
|
});
|
|
|
|
assert.notEqual(createdSession.sessionTokenHash, result.sessionToken);
|
|
assert.notEqual(createdSession.csrfTokenHash, result.csrfToken);
|
|
assert.equal(createdSession.userId, 'user-id');
|
|
assert.equal(result.principal.sellerId, 'seller-id');
|
|
assert.deepEqual(auditEvents, [
|
|
{ userId: 'user-id', event: 'LOGIN_SUCCESS', ipAddress: null, userAgent: null },
|
|
{ userId: 'user-id', event: 'SESSION_CREATED', ipAddress: null, userAgent: null },
|
|
]);
|
|
});
|
|
|
|
test('does not create a session for invalid credentials', async () => {
|
|
let sessionCreated = false;
|
|
const auditEvents = [];
|
|
const service = createAuthenticationService({
|
|
passwordVerifier: async () => false,
|
|
sessionRepository: {
|
|
async create() {
|
|
sessionCreated = true;
|
|
},
|
|
},
|
|
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
|
userRepository: {
|
|
async findAuthenticationUserByEmail() {
|
|
return { id: 'user-id', passwordHash: 'stored-hash' };
|
|
},
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => service.login({ email: 'admin@example.test', password: 'incorrect-password' }),
|
|
(error) => error.code === 'UNAUTHORIZED',
|
|
);
|
|
assert.equal(sessionCreated, false);
|
|
assert.deepEqual(auditEvents, [{ event: 'LOGIN_FAILURE', ipAddress: null, userAgent: null }]);
|
|
});
|
|
|
|
test('does not create a session until enabled MFA verifies the login code', async () => {
|
|
let sessionCreated = false;
|
|
let receivedMfaInput;
|
|
const service = createAuthenticationService({
|
|
mfaService: {
|
|
async verifyLogin(input) {
|
|
receivedMfaInput = input;
|
|
const error = new Error('MFA code is invalid.');
|
|
error.code = 'MFA_CODE_INVALID';
|
|
throw error;
|
|
},
|
|
},
|
|
passwordVerifier: async () => true,
|
|
sessionRepository: { async create() { sessionCreated = true; } },
|
|
userRepository: {
|
|
async findAuthenticationUserByEmail() {
|
|
return {
|
|
id: 'user-id',
|
|
sellerId: 'seller-id',
|
|
role: 'ADMIN',
|
|
passwordHash: 'stored-hash',
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => service.login({ email: 'admin@example.test', password: 'correct-password' }),
|
|
(error) => error.code === 'MFA_CODE_INVALID',
|
|
);
|
|
|
|
assert.deepEqual(receivedMfaInput, { userId: 'user-id', code: undefined });
|
|
assert.equal(sessionCreated, false);
|
|
});
|
|
|
|
test('rejects an unsafe request without the session-bound CSRF token', async () => {
|
|
const service = createAuthenticationService({
|
|
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
|
sessionRepository: {
|
|
async findActiveAuthenticationSession() {
|
|
return { csrfTokenHash: 'different-token-hash' };
|
|
},
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => service.authorizeUnsafeRequest('session-token', 'csrf-token'),
|
|
(error) => error.code === 'FORBIDDEN',
|
|
);
|
|
});
|
|
|
|
test('lists and revokes only sessions owned by the authenticated user', async () => {
|
|
let revokedSession;
|
|
let revokedOthers;
|
|
const auditEvents = [];
|
|
const service = createAuthenticationService({
|
|
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
|
sessionRepository: {
|
|
async findActiveAuthenticationSession() {
|
|
return { id: 'current-session', userId: 'user-id', sellerId: 'seller-id', role: 'ADMIN' };
|
|
},
|
|
async listActiveByUserId(userId) {
|
|
assert.equal(userId, 'user-id');
|
|
return [{ id: 'current-session' }, { id: 'other-session' }];
|
|
},
|
|
async revokeByIdAndUserId(id, userId) {
|
|
revokedSession = { id, userId };
|
|
return true;
|
|
},
|
|
async revokeOtherByUserId(userId, currentSessionId) {
|
|
revokedOthers = { userId, currentSessionId };
|
|
},
|
|
},
|
|
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
|
});
|
|
|
|
const sessions = await service.listActiveSessions('session-token');
|
|
const revoked = await service.revokeSession('session-token', 'other-session');
|
|
await service.revokeOtherSessions('session-token');
|
|
|
|
assert.equal(sessions.currentSessionId, 'current-session');
|
|
assert.deepEqual(revokedSession, { id: 'other-session', userId: 'user-id' });
|
|
assert.equal(revoked.currentSessionRevoked, false);
|
|
assert.deepEqual(revokedOthers, { userId: 'user-id', currentSessionId: 'current-session' });
|
|
assert.deepEqual(auditEvents, [
|
|
{
|
|
userId: 'user-id',
|
|
event: 'SESSION_REVOKED',
|
|
metadata: { targetSessionId: 'other-session' },
|
|
},
|
|
{
|
|
userId: 'user-id',
|
|
event: 'SESSION_REVOKED',
|
|
metadata: { scope: 'OTHER_SESSIONS' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('renews stale active session activity without updating recent activity', async () => {
|
|
let touchedAt;
|
|
const currentTime = new Date('2026-09-07T01:00:00.000Z');
|
|
const service = createAuthenticationService({
|
|
now: () => currentTime,
|
|
sessionRepository: {
|
|
async findActiveAuthenticationSession() {
|
|
return {
|
|
id: 'current-session',
|
|
userId: 'user-id',
|
|
sellerId: 'seller-id',
|
|
role: 'ADMIN',
|
|
lastActivityAt: new Date('2026-09-07T00:50:00.000Z'),
|
|
};
|
|
},
|
|
async touchById(id, activityTime) {
|
|
touchedAt = { id, activityTime };
|
|
},
|
|
},
|
|
});
|
|
|
|
await service.authenticate('session-token');
|
|
|
|
assert.deepEqual(touchedAt, { id: 'current-session', activityTime: currentTime });
|
|
}); |