Replaces the no-op fastify.authenticate stub and the never-implemented
identity/auth login with real bcrypt password verification, JWT session
issuance/verification (reusing the existing JWT_SECRET), and a Redis-backed
revocation denylist for logout. Adds requireRole('ADMIN') to admin-only
configuration writes across 002-009 that previously relied on a decorator
that never actually checked anything. Adds self-identity (GET /auth/me,
re-validated against live account state) and admin-provisioned accounts
(POST /admin/users).
Making the auth check genuinely reject invalid/missing tokens exposed that
~18 pre-existing integration test files called already-gated routes with no
Authorization header (safe against the old no-op stub, broken against a real
one) — fixed via a shared tests/helpers/auth.ts (loginAs/authHeader) and a
file-by-file pass, plus two related SLA-run cleanup races exposed once admin
setup calls in those files' own beforeAll blocks started actually succeeding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
253 lines
9.1 KiB
TypeScript
253 lines
9.1 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { buildApp } from '@/app';
|
|
import { prismaClient } from '@/infrastructure/database';
|
|
import { FastifyInstance } from 'fastify';
|
|
import bcrypt from 'bcryptjs';
|
|
|
|
/**
|
|
* Covers specs/010-identity-auth/quickstart.md Scenarios 1-5 against a real Postgres/Redis —
|
|
* real login with identical-failure-response parity, real route/role gating (spot-checked
|
|
* against one already-shipped admin route per 002-009), self-identity re-validated against live
|
|
* account state, admin-provisioned accounts, and logout revocation.
|
|
*/
|
|
describe('Identity and authentication — full flow (User Stories 1-5)', () => {
|
|
let app: FastifyInstance;
|
|
const suffix = Date.now();
|
|
const adminEmail = `identity-admin-${suffix}@supporthub.test`;
|
|
const agentEmail = `identity-agent-${suffix}@supporthub.test`;
|
|
const password = 'Correct-Horse-Battery-Staple-1!';
|
|
const createdUserIds: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp();
|
|
const admin = await prismaClient.user.create({
|
|
data: {
|
|
email: adminEmail,
|
|
name: 'Identity Test Admin',
|
|
role: 'ADMIN',
|
|
passwordHash: await bcrypt.hash(password, 10),
|
|
},
|
|
});
|
|
createdUserIds.push(admin.id);
|
|
const agent = await prismaClient.user.create({
|
|
data: {
|
|
email: agentEmail,
|
|
name: 'Identity Test Agent',
|
|
role: 'AGENT',
|
|
passwordHash: await bcrypt.hash(password, 10),
|
|
},
|
|
});
|
|
createdUserIds.push(agent.id);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prismaClient.user.deleteMany({ where: { id: { in: createdUserIds } } });
|
|
await app.close();
|
|
});
|
|
|
|
it('Scenario 1: login succeeds with a token + identity; wrong password and unknown email are indistinguishable', async () => {
|
|
const success = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: adminEmail, password },
|
|
});
|
|
expect(success.statusCode).toBe(200);
|
|
const successBody = success.json();
|
|
expect(typeof successBody.data.token).toBe('string');
|
|
expect(successBody.data.user).toMatchObject({ email: adminEmail, role: 'ADMIN' });
|
|
|
|
const wrongPassword = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: adminEmail, password: 'not-the-password' },
|
|
});
|
|
const unknownEmail = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: `nobody-${suffix}@supporthub.test`, password },
|
|
});
|
|
|
|
expect(wrongPassword.statusCode).toBe(401);
|
|
expect(unknownEmail.statusCode).toBe(401);
|
|
// requestId is a per-request trace id, expected to differ — everything else (the part that
|
|
// could leak which failure branch fired) must be byte-identical.
|
|
expect(wrongPassword.json().success).toBe(unknownEmail.json().success);
|
|
expect(wrongPassword.json().error).toEqual(unknownEmail.json().error);
|
|
});
|
|
|
|
it('Scenario 2: route gating and role enforcement, spot-checked across 002-009 admin routes', async () => {
|
|
const adminLogin = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: adminEmail, password },
|
|
});
|
|
const adminToken = adminLogin.json().data.token as string;
|
|
|
|
const agentLogin = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: agentEmail, password },
|
|
});
|
|
const agentToken = agentLogin.json().data.token as string;
|
|
|
|
const noHeader = await app.inject({ method: 'POST', url: '/admin/teams', payload: {} });
|
|
expect(noHeader.statusCode).toBe(401);
|
|
|
|
const malformed = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/teams',
|
|
headers: { authorization: 'Bearer not-a-real-token' },
|
|
payload: {},
|
|
});
|
|
expect(malformed.statusCode).toBe(401);
|
|
|
|
const wrongRole = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/teams',
|
|
headers: { authorization: `Bearer ${agentToken}` },
|
|
payload: { name: `Should Be Rejected ${suffix}` },
|
|
});
|
|
expect(wrongRole.statusCode).toBe(403);
|
|
|
|
const correctRole = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/teams',
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { name: `Identity Test Team ${suffix}` },
|
|
});
|
|
expect(correctRole.statusCode).toBe(201);
|
|
await prismaClient.team.deleteMany({ where: { id: correctRole.json().data.id } });
|
|
|
|
// Cross-module spot-check: one already-shipped admin route per feature, not just this
|
|
// feature's own routes, rejects a missing session — proving the real gate protects what the
|
|
// no-op stub never did.
|
|
const spotChecks = [
|
|
{ method: 'PATCH' as const, url: '/admin/integrations/nonexistent-id/status' }, // 002
|
|
{ method: 'POST' as const, url: '/admin/products/nonexistent-id/knowledge' }, // 004
|
|
{ method: 'PATCH' as const, url: `/admin/hierarchy-nodes/nonexistent-id/activate` }, // 006
|
|
{ method: 'POST' as const, url: '/admin/tickets/nonexistent-id/assignment' }, // 007
|
|
{ method: 'POST' as const, url: '/admin/sla-policies' }, // 008
|
|
{ method: 'POST' as const, url: '/admin/escalation-policies' }, // 008
|
|
{ method: 'POST' as const, url: '/admin/problems/nonexistent-id/investigations' }, // 009
|
|
];
|
|
for (const spotCheck of spotChecks) {
|
|
const res = await app.inject({ ...spotCheck, payload: {} });
|
|
expect(res.statusCode, `${spotCheck.method} ${spotCheck.url}`).toBe(401);
|
|
}
|
|
});
|
|
|
|
it('Scenario 3: self-identity matches login, and is re-validated against live account state', async () => {
|
|
const deactivatable = await prismaClient.user.create({
|
|
data: {
|
|
email: `identity-deactivate-${suffix}@supporthub.test`,
|
|
name: 'Deactivate Me',
|
|
role: 'AGENT',
|
|
passwordHash: await bcrypt.hash(password, 10),
|
|
},
|
|
});
|
|
createdUserIds.push(deactivatable.id);
|
|
|
|
const login = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: deactivatable.email, password },
|
|
});
|
|
const token = login.json().data.token as string;
|
|
|
|
const me = await app.inject({
|
|
method: 'GET',
|
|
url: '/auth/me',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
});
|
|
expect(me.statusCode).toBe(200);
|
|
expect(me.json().data).toEqual(login.json().data.user);
|
|
|
|
await prismaClient.user.update({ where: { id: deactivatable.id }, data: { active: false } });
|
|
|
|
const meAfterDeactivation = await app.inject({
|
|
method: 'GET',
|
|
url: '/auth/me',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
});
|
|
expect(meAfterDeactivation.statusCode).toBe(401);
|
|
});
|
|
|
|
it('Scenario 4: an admin provisions an account, immediately usable to log in', async () => {
|
|
const adminLogin = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: adminEmail, password },
|
|
});
|
|
const adminToken = adminLogin.json().data.token as string;
|
|
const agentLogin = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: agentEmail, password },
|
|
});
|
|
const agentToken = agentLogin.json().data.token as string;
|
|
|
|
const newAccountEmail = `identity-provisioned-${suffix}@supporthub.test`;
|
|
const created = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/users',
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { email: newAccountEmail, name: 'Provisioned Agent', role: 'AGENT', password },
|
|
});
|
|
expect(created.statusCode).toBe(201);
|
|
expect(created.json().data.passwordHash).toBeUndefined();
|
|
expect(created.json().data.password).toBeUndefined();
|
|
createdUserIds.push(created.json().data.id);
|
|
|
|
const nonAdminAttempt = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/users',
|
|
headers: { authorization: `Bearer ${agentToken}` },
|
|
payload: {
|
|
email: `identity-rejected-${suffix}@supporthub.test`,
|
|
name: 'Should Be Rejected',
|
|
role: 'AGENT',
|
|
password,
|
|
},
|
|
});
|
|
expect(nonAdminAttempt.statusCode).toBe(403);
|
|
|
|
const newAccountLogin = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: newAccountEmail, password },
|
|
});
|
|
expect(newAccountLogin.statusCode).toBe(200);
|
|
|
|
const duplicate = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/users',
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { email: newAccountEmail, name: 'Duplicate', role: 'AGENT', password },
|
|
});
|
|
expect(duplicate.statusCode).toBe(409);
|
|
});
|
|
|
|
it('Scenario 5: logout immediately revokes the token, even though it has not naturally expired', async () => {
|
|
const login = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/login',
|
|
payload: { email: agentEmail, password },
|
|
});
|
|
const token = login.json().data.token as string;
|
|
|
|
const logout = await app.inject({
|
|
method: 'POST',
|
|
url: '/auth/logout',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
});
|
|
expect(logout.statusCode).toBe(200);
|
|
|
|
const reuse = await app.inject({
|
|
method: 'GET',
|
|
url: '/auth/me',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
});
|
|
expect(reuse.statusCode).toBe(401);
|
|
});
|
|
});
|