First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
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', adminUserId: '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', adminUserId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1' });
|
|
|
|
const data = prisma.adminSession.create.mock.calls[0][0].data;
|
|
expect(data).toMatchObject({ jti: 'jti-1', adminUserId: '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', adminUserId: '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: { adminUserId: '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();
|
|
});
|
|
});
|