AdminUser held both panel staff and sales experts, so one mobile could not be both. Staff now live in "Admin" (panel only); sales experts stay in "AdminUser" untouched. Uniqueness is per table. - migration 20261026120000_split_admin_table: additive only. Creates "Admin", copies non-expert rows with their ids, re-points the "who did this" foreign keys (ledger, order status, sms, notification, upload, allocation, workshop, admin session) to "Admin" without moving data. Never writes to "AdminUser". - login, JWT, SSO, branch/workshop supervisors and the users grid use "Admin"; SALES_EXPERT create/edit still goes through admin/users into "AdminUser". - an admin cannot be turned into an expert or the reverse (different tables). - manual/: optional phase-2 script to drop the dormant admin copies from "AdminUser", and a read-only expert-login diagnostic. - docs/ADMIN-TABLE-SPLIT.md: what changed, deploy checklist, rollback. Tested on a copy with 12,000 experts: expert rows byte-identical (md5) before and after; full migration chain applies and matches schema.prisma. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
165 lines
6.5 KiB
TypeScript
165 lines
6.5 KiB
TypeScript
import { AdminSessionService } from './admin-session.service';
|
|
import type { IamService } from '../iam/iam.service';
|
|
|
|
/**
|
|
* Revocable admin sessions.
|
|
*
|
|
* Two contracts are locked down here. First, with tracking off nothing changes:
|
|
* no `jti`, no row, and no extra query on an authenticated request. Second, with
|
|
* it on, a revoked session stops working immediately — which is the whole point,
|
|
* because before this table an issued token was usable for its full 12h no matter
|
|
* what happened centrally.
|
|
*/
|
|
describe('AdminSessionService', () => {
|
|
const ORIGINAL_ENV = process.env;
|
|
|
|
const build = (opts: { iamEnabled?: boolean; tracking?: string | null } = {}) => {
|
|
process.env = { ...ORIGINAL_ENV };
|
|
delete process.env.AUTH_SESSION_TRACKING;
|
|
if (opts.tracking != null) process.env.AUTH_SESSION_TRACKING = opts.tracking;
|
|
|
|
const prisma = {
|
|
adminSession: {
|
|
create: jest.fn().mockResolvedValue({}),
|
|
findUnique: jest.fn(),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 2 }),
|
|
},
|
|
};
|
|
const iam = { enabled: opts.iamEnabled ?? false } as IamService;
|
|
|
|
return { service: new AdminSessionService(prisma as never, iam), prisma };
|
|
};
|
|
|
|
afterEach(() => {
|
|
process.env = ORIGINAL_ENV;
|
|
});
|
|
|
|
/* ─────────────── off by default ─────────────── */
|
|
|
|
it('is off in a deployment that does not use the identity service', () => {
|
|
expect(build().service.enabled).toBe(false);
|
|
});
|
|
|
|
it('turns itself on with the identity service, so central revocation reaches us', () => {
|
|
expect(build({ iamEnabled: true }).service.enabled).toBe(true);
|
|
});
|
|
|
|
it('can be turned on on its own', () => {
|
|
expect(build({ tracking: 'true' }).service.enabled).toBe(true);
|
|
});
|
|
|
|
it('can be turned off explicitly even with the identity service on', () => {
|
|
// Opting out of the per-request lookup; central revocation then cannot reach here
|
|
expect(build({ iamEnabled: true, tracking: 'false' }).service.enabled).toBe(false);
|
|
});
|
|
|
|
/* ─────────────── unchanged behaviour while off ─────────────── */
|
|
|
|
it('issues no token id and writes no row while off', async () => {
|
|
const { service, prisma } = build();
|
|
|
|
expect(service.newTokenId()).toBeNull();
|
|
await service.record({ jti: 'x', adminId: 'a' });
|
|
|
|
expect(prisma.adminSession.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('authenticates without a database lookup while off', async () => {
|
|
const { service, prisma } = build();
|
|
|
|
await expect(service.isActive('some-jti')).resolves.toBe(true);
|
|
expect(prisma.adminSession.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/* ─────────────── enforcement while on ─────────────── */
|
|
|
|
it('accepts a live session', async () => {
|
|
const { service, prisma } = build({ iamEnabled: true });
|
|
prisma.adminSession.findUnique.mockResolvedValue({ revokedAt: null });
|
|
|
|
await expect(service.isActive('jti-1')).resolves.toBe(true);
|
|
});
|
|
|
|
it('refuses a revoked session', async () => {
|
|
const { service, prisma } = build({ iamEnabled: true });
|
|
prisma.adminSession.findUnique.mockResolvedValue({ revokedAt: new Date() });
|
|
|
|
// The gap this closes: previously the token kept working until it expired
|
|
await expect(service.isActive('jti-1')).resolves.toBe(false);
|
|
});
|
|
|
|
it('refuses a token whose session row is gone', async () => {
|
|
const { service, prisma } = build({ iamEnabled: true });
|
|
prisma.adminSession.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(service.isActive('jti-1')).resolves.toBe(false);
|
|
});
|
|
|
|
it('keeps tokens issued before tracking was switched on working', async () => {
|
|
const { service, prisma } = build({ iamEnabled: true });
|
|
|
|
// Such a token carries no jti; refusing it would sign every admin out at once
|
|
await expect(service.isActive(undefined)).resolves.toBe(true);
|
|
expect(prisma.adminSession.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/* ─────────────── recording and revoking ─────────────── */
|
|
|
|
it('records the session with an expiry derived from the token lifetime', async () => {
|
|
process.env.JWT_EXPIRES_IN = '12h';
|
|
const { service, prisma } = build({ iamEnabled: true });
|
|
process.env.JWT_EXPIRES_IN = '12h';
|
|
|
|
await service.record({ jti: 'jti-1', adminId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1' });
|
|
|
|
const data = prisma.adminSession.create.mock.calls[0][0].data;
|
|
expect(data).toMatchObject({ jti: 'jti-1', adminId: 'admin-1', iamSessionId: 'sid-1' });
|
|
expect(data.expiresAt.getTime()).toBeGreaterThan(Date.now() + 11 * 3600 * 1000);
|
|
});
|
|
|
|
it('never breaks login when the session row cannot be written', async () => {
|
|
const { service, prisma } = build({ iamEnabled: true });
|
|
prisma.adminSession.create.mockRejectedValue(new Error('unique violation'));
|
|
|
|
// Worst case is a session that cannot be revoked early — not a failed login
|
|
await expect(service.record({ jti: 'jti-1', adminId: 'a' })).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('revokes by the identity service session id', async () => {
|
|
const { service, prisma } = build({ iamEnabled: true });
|
|
|
|
await expect(service.revokeByIamSession('sid-1')).resolves.toBe(2);
|
|
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
|
|
where: { iamSessionId: 'sid-1', revokedAt: null },
|
|
data: expect.objectContaining({ revokedReason: 'iam_backchannel_logout' }),
|
|
});
|
|
});
|
|
|
|
it('revokes every session of an identity when the token names only a subject', async () => {
|
|
const { service, prisma } = build({ iamEnabled: true });
|
|
|
|
await service.revokeByIamSubject('sub-1');
|
|
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
|
|
where: { iamSub: 'sub-1', revokedAt: null },
|
|
data: expect.objectContaining({ revokedReason: 'iam_backchannel_logout' }),
|
|
});
|
|
});
|
|
|
|
it('revokes every session of a local admin', async () => {
|
|
const { service, prisma } = build({ iamEnabled: true });
|
|
|
|
await service.revokeAllForAdmin('admin-1', 'account_disabled');
|
|
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
|
|
where: { adminId: 'admin-1', revokedAt: null },
|
|
data: expect.objectContaining({ revokedReason: 'account_disabled' }),
|
|
});
|
|
});
|
|
|
|
it('does not touch anything while off', async () => {
|
|
const { service, prisma } = build();
|
|
|
|
await expect(service.revokeByIamSession('sid-1')).resolves.toBe(0);
|
|
expect(prisma.adminSession.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
});
|