manicanldes-backend/test/identity-auth.spec.ts

162 lines
5.2 KiB
TypeScript

import {
identityApp,
type IdentityApp,
ownerPassword,
} from './helpers/identity-app';
import { hashToken } from '../src/identity/tokens';
describe('authentication with migrated PostgreSQL engine', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx?.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
it('stores only hashed session tokens and never exposes password hashes', async () => {
const session = await ctx.db.session.findUniqueOrThrow({
where: { tokenHash: hashToken(ctx.token) },
});
expect(session.tokenHash).not.toBe(ctx.token);
const me = await ctx
.api()
.get('/api/v1/auth/me')
.auth(ctx.token, { type: 'bearer' })
.expect(200);
expect(me.body.organizationId).toBe(ctx.owner.organizationId);
expect(me.text).not.toContain('password');
expect(me.headers['cache-control']).toBe('no-store');
});
it('rejects missing, malformed, unknown and expired sessions', async () => {
await ctx.api().get('/api/v1/users').expect(401);
await ctx
.api()
.get('/api/v1/users')
.set('Authorization', 'Bearer bad')
.expect(401);
await ctx
.api()
.get('/api/v1/users')
.auth('a'.repeat(43), { type: 'bearer' })
.expect(401);
const login = await ctx.login();
await ctx.db.session.update({
where: { tokenHash: hashToken(login.body.accessToken) },
data: { expiresAt: new Date(0) },
});
await ctx
.api()
.get('/api/v1/auth/me')
.auth(login.body.accessToken, { type: 'bearer' })
.expect(401);
});
it('returns the same credential error for unknown email and wrong password', async () => {
const missing = await ctx.login('missing@example.com');
const wrong = await ctx.login('owner@example.com', 'wrong password');
expect(missing.status).toBe(401);
expect(wrong.body.code).toBe(missing.body.code);
expect(wrong.body.message).toBe(missing.body.message);
});
it('validates payloads without echoing secrets and rejects mass assignment', async () => {
const response = await ctx
.api()
.post('/api/v1/auth/login')
.send({
organizationId: ctx.owner.organizationId,
email: 'owner@example.com',
password: 'SECRET',
isOwner: true,
})
.expect(400);
expect(response.text).not.toContain('SECRET');
});
it('revokes a session on logout', async () => {
const login = await ctx.login();
const bearer = login.body.accessToken;
await ctx
.api()
.post('/api/v1/auth/logout')
.auth(bearer, { type: 'bearer' })
.expect(204);
await ctx
.api()
.get('/api/v1/auth/me')
.auth(bearer, { type: 'bearer' })
.expect(401);
});
it('limits repeated attempts per account in durable storage', async () => {
for (let i = 0; i < 10; i++)
expect((await ctx.login('unknown@example.com')).status).toBe(401);
expect((await ctx.login('unknown@example.com')).status).toBe(429);
}, 15000);
it('enforces IP throttling even for malformed input', async () => {
for (let i = 0; i < 30; i++)
await ctx.api().post('/api/v1/auth/login').send({}).expect(400);
await ctx.api().post('/api/v1/auth/login').send({}).expect(429);
});
it('recovery consumes a token once and revokes existing sessions', async () => {
await ctx
.api()
.post('/api/v1/auth/recovery/request')
.send({
organizationId: ctx.owner.organizationId,
email: 'owner@example.com',
})
.expect(202);
const recoveryToken = ctx.mailer.send.mock.calls.at(-1)![1] as string;
const newPassword = 'a replacement secure passphrase';
await ctx
.api()
.post('/api/v1/auth/recovery/reset')
.send({ token: recoveryToken, password: newPassword })
.expect(204);
await ctx
.api()
.post('/api/v1/auth/recovery/reset')
.send({ token: recoveryToken, password: newPassword })
.expect(400);
await ctx
.api()
.get('/api/v1/auth/me')
.auth(ctx.token, { type: 'bearer' })
.expect(401);
expect((await ctx.login('owner@example.com', ownerPassword)).status).toBe(
401,
);
const login = await ctx.login('owner@example.com', newPassword);
expect(login.status).toBe(200);
ctx.token = login.body.accessToken;
});
it('revokes all sessions and records security events without secrets', async () => {
const login = await ctx.login(
'owner@example.com',
'a replacement secure passphrase',
);
await ctx
.api()
.post('/api/v1/auth/logout-all')
.auth(ctx.token, { type: 'bearer' })
.expect(204);
await ctx
.api()
.get('/api/v1/auth/me')
.auth(login.body.accessToken, { type: 'bearer' })
.expect(401);
const events = await ctx.db.auditEvent.findMany();
expect(events.map((event) => event.action)).toEqual(
expect.arrayContaining([
'auth.login',
'auth.login_failed',
'auth.password_reset',
'auth.logout_all',
]),
);
expect(JSON.stringify(events)).not.toContain(ctx.token);
expect(JSON.stringify(events)).not.toContain(ownerPassword);
});
});