Files
support_backend/tests/integration/identity-auth-flow.test.ts
T
saqib mirandClaude Sonnet 5 40687f68fa feat(010-identity-auth): real staff login, session verification, and role gating
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>
2026-09-07 12:45:37 +05:30

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);
});
});