Files
tehran_taj/apps/api/src/modules/sessions/admin-session.service.spec.ts
T
s.esmaeiliandClaude Sonnet 5.5 06c8a5f434 feat(auth): split panel admins out of AdminUser into their own Admin table
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>
2026-10-25 23:52:33 +03:30

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