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).
340 lines
10 KiB
JavaScript
340 lines
10 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import { buildApp } from '../../src/app/build-app.js';
|
|
import { createRedisLoginRateLimiter } from '../../src/modules/auth/services/login-rate-limiter.js';
|
|
|
|
function createTestApp(authenticationService, dependencies = {}) {
|
|
return buildApp({
|
|
authenticationService,
|
|
loginRateLimiter: { async consume() { return { allowed: true }; } },
|
|
...dependencies,
|
|
});
|
|
}
|
|
|
|
test('places a successful login session only in a strict HttpOnly cookie', async (testContext) => {
|
|
const app = createTestApp({
|
|
async login() {
|
|
return {
|
|
sessionToken: 'raw-session-token',
|
|
csrfToken: 'raw-csrf-token',
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
};
|
|
},
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { email: 'admin@example.test', password: 'correct-password' },
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(response.json().data.sessionToken, undefined);
|
|
const cookies = response.headers['set-cookie'].join('\n');
|
|
assert.match(cookies, /HttpOnly/);
|
|
assert.match(cookies, /SameSite=Strict/);
|
|
assert.match(cookies, /pokemon_inventory_session=raw-session-token/);
|
|
assert.match(cookies, /pokemon_inventory_csrf=raw-csrf-token/);
|
|
});
|
|
|
|
test('rejects malformed login bodies before authentication', async (testContext) => {
|
|
const app = createTestApp({
|
|
async login() {
|
|
throw new Error('Authentication should not run.');
|
|
},
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { email: 'not-an-email', password: '' },
|
|
});
|
|
|
|
assert.equal(response.statusCode, 400);
|
|
assert.equal(response.json().error.code, 'VALIDATION_ERROR');
|
|
});
|
|
|
|
test('reads the authenticated principal from the session cookie', async (testContext) => {
|
|
const app = createTestApp({
|
|
async authenticate(sessionToken) {
|
|
assert.equal(sessionToken, 'raw-session-token');
|
|
return { userId: 'user-id', sellerId: 'seller-id', role: 'ADMIN' };
|
|
},
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/me',
|
|
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(response.json().data.sellerId, 'seller-id');
|
|
});
|
|
|
|
test('revokes the session and clears its cookie on logout', async (testContext) => {
|
|
let revokedToken;
|
|
const app = createTestApp({
|
|
async authorizeUnsafeRequest(sessionToken, csrfToken) {
|
|
assert.equal(sessionToken, 'raw-session-token');
|
|
assert.equal(csrfToken, 'raw-csrf-token');
|
|
return { userId: 'user-id' };
|
|
},
|
|
async logout(sessionToken) {
|
|
revokedToken = sessionToken;
|
|
},
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/logout',
|
|
headers: {
|
|
cookie: 'pokemon_inventory_session=raw-session-token',
|
|
'x-csrf-token': 'raw-csrf-token',
|
|
},
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(revokedToken, 'raw-session-token');
|
|
assert.match(response.headers['set-cookie'].join('\n'), /Max-Age=0/);
|
|
});
|
|
|
|
test('rejects logout without a session-bound CSRF token', async (testContext) => {
|
|
const app = createTestApp({
|
|
async authorizeUnsafeRequest() {
|
|
const error = new Error('CSRF validation failed.');
|
|
error.code = 'FORBIDDEN';
|
|
error.statusCode = 403;
|
|
throw error;
|
|
},
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/logout',
|
|
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
|
});
|
|
|
|
assert.equal(response.statusCode, 403);
|
|
assert.equal(response.json().error.code, 'FORBIDDEN');
|
|
});
|
|
|
|
test('limits login attempts by client before credential verification', async (testContext) => {
|
|
let loginCalls = 0;
|
|
const attemptsByKey = new Map();
|
|
const redisClient = {
|
|
async eval(script, { arguments: scriptArguments, keys }) {
|
|
assert.match(script, /INCR/);
|
|
assert.equal(scriptArguments[0], String(15 * 60 * 1000));
|
|
const key = keys[0];
|
|
const count = (attemptsByKey.get(key) ?? 0) + 1;
|
|
attemptsByKey.set(key, count);
|
|
return [count, 15 * 60 * 1000];
|
|
},
|
|
};
|
|
const app = createTestApp(
|
|
{
|
|
async login() {
|
|
loginCalls += 1;
|
|
throw new Error('Invalid credentials');
|
|
},
|
|
},
|
|
{
|
|
loginRateLimiter: createRedisLoginRateLimiter({
|
|
maxAttempts: 2,
|
|
redisClient,
|
|
}),
|
|
},
|
|
);
|
|
testContext.after(() => app.close());
|
|
|
|
const request = {
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { email: 'admin@example.test', password: 'incorrect-password' },
|
|
remoteAddress: '198.51.100.8',
|
|
};
|
|
|
|
const firstResponse = await app.inject(request);
|
|
const secondResponse = await app.inject(request);
|
|
const blockedResponse = await app.inject(request);
|
|
const otherClientResponse = await app.inject({
|
|
...request,
|
|
remoteAddress: '198.51.100.9',
|
|
});
|
|
|
|
assert.equal(firstResponse.statusCode, 500);
|
|
assert.equal(secondResponse.statusCode, 500);
|
|
assert.equal(blockedResponse.statusCode, 429);
|
|
assert.equal(blockedResponse.json().error.code, 'AUTH_RATE_LIMITED');
|
|
assert.equal(blockedResponse.headers['retry-after'], '900');
|
|
assert.equal(otherClientResponse.statusCode, 500);
|
|
assert.equal(loginCalls, 3);
|
|
});
|
|
|
|
test('lists active sessions without exposing credential material', async (testContext) => {
|
|
const app = createTestApp({
|
|
async listActiveSessions(sessionToken) {
|
|
assert.equal(sessionToken, 'raw-session-token');
|
|
return {
|
|
currentSessionId: '3d0a3713-5b8f-4080-af25-b2e6eb4150b1',
|
|
sessions: [
|
|
{
|
|
id: '3d0a3713-5b8f-4080-af25-b2e6eb4150b1',
|
|
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
|
expiresAt: new Date('2026-09-08T00:00:00.000Z'),
|
|
lastActivityAt: new Date('2026-09-07T00:30:00.000Z'),
|
|
ipAddress: '198.51.100.8',
|
|
userAgent: 'Test Browser',
|
|
sessionTokenHash: 'must-not-be-serialized',
|
|
},
|
|
],
|
|
};
|
|
},
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/sessions',
|
|
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(response.json().data[0].isCurrent, true);
|
|
assert.equal(response.json().data[0].sessionTokenHash, undefined);
|
|
});
|
|
|
|
test('requires CSRF validation to revoke a session and clears current-session cookies', async (testContext) => {
|
|
const app = createTestApp({
|
|
async authorizeUnsafeRequest(sessionToken, csrfToken) {
|
|
assert.equal(sessionToken, 'raw-session-token');
|
|
assert.equal(csrfToken, 'raw-csrf-token');
|
|
},
|
|
async revokeSession(sessionToken, sessionId) {
|
|
assert.equal(sessionToken, 'raw-session-token');
|
|
assert.equal(sessionId, '3d0a3713-5b8f-4080-af25-b2e6eb4150b1');
|
|
return { currentSessionRevoked: true };
|
|
},
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'DELETE',
|
|
url: '/api/v1/auth/sessions/3d0a3713-5b8f-4080-af25-b2e6eb4150b1',
|
|
headers: {
|
|
cookie: 'pokemon_inventory_session=raw-session-token',
|
|
'x-csrf-token': 'raw-csrf-token',
|
|
},
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.match(response.headers['set-cookie'].join('\n'), /pokemon_inventory_session=; Max-Age=0/);
|
|
assert.match(response.headers['set-cookie'].join('\n'), /pokemon_inventory_csrf=; Max-Age=0/);
|
|
});
|
|
|
|
test('revokes other sessions while retaining the current session', async (testContext) => {
|
|
let revokedOtherSessions = false;
|
|
const app = createTestApp({
|
|
async authorizeUnsafeRequest() {},
|
|
async revokeOtherSessions(sessionToken) {
|
|
assert.equal(sessionToken, 'raw-session-token');
|
|
revokedOtherSessions = true;
|
|
},
|
|
});
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'DELETE',
|
|
url: '/api/v1/auth/sessions',
|
|
headers: {
|
|
cookie: 'pokemon_inventory_session=raw-session-token',
|
|
'x-csrf-token': 'raw-csrf-token',
|
|
},
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(revokedOtherSessions, true);
|
|
assert.equal(response.headers['set-cookie'], undefined);
|
|
});
|
|
|
|
test('accepts password reset requests without returning a reset token', async (testContext) => {
|
|
let resetRequest;
|
|
const app = createTestApp(
|
|
{},
|
|
{
|
|
passwordRecoveryService: {
|
|
async requestReset(input) {
|
|
resetRequest = input;
|
|
},
|
|
},
|
|
},
|
|
);
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/forgot-password',
|
|
payload: { email: 'admin@example.test' },
|
|
});
|
|
|
|
assert.equal(response.statusCode, 202);
|
|
assert.equal(response.json().data, null);
|
|
assert.equal('token' in response.json(), false);
|
|
assert.equal(resetRequest.email, 'admin@example.test');
|
|
});
|
|
|
|
test('resets a password only with a valid-shaped recovery token', async (testContext) => {
|
|
let resetInput;
|
|
const app = createTestApp(
|
|
{},
|
|
{
|
|
passwordRecoveryService: {
|
|
async resetPassword(input) {
|
|
resetInput = input;
|
|
},
|
|
},
|
|
},
|
|
);
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/reset-password',
|
|
payload: { token: 'a'.repeat(32), password: 'new-password-value' },
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(resetInput.password, 'new-password-value');
|
|
});
|
|
|
|
test('limits password reset requests independently from login attempts', async (testContext) => {
|
|
const app = createTestApp(
|
|
{},
|
|
{
|
|
passwordRecoveryService: { async requestReset() {} },
|
|
passwordResetRateLimiter: {
|
|
async consume() {
|
|
return { allowed: false, retryAfterSeconds: 60 };
|
|
},
|
|
},
|
|
},
|
|
);
|
|
testContext.after(() => app.close());
|
|
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/forgot-password',
|
|
payload: { email: 'admin@example.test' },
|
|
});
|
|
|
|
assert.equal(response.statusCode, 429);
|
|
assert.equal(response.json().error.code, 'AUTH_RATE_LIMITED');
|
|
assert.equal(response.headers['retry-after'], '60');
|
|
}); |