feat: establish auth listing and image vertical slices
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).
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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 });
|
||||
});
|
||||
Reference in New Issue
Block a user