Root company app (src/, client/) — GL foundation, AR, and AP: - Segmented Chart of Accounts (Sage 300-style), double-entry PostingService with three-layer balance enforcement (app validation, CHECK constraints, deferred DB trigger), gap-free document numbering, RBAC with branch restrictions. - Accounts Receivable: Customers, Invoices, Credit Notes, Receipts with allocation, all posting through PostingService. - Accounts Payable: Suppliers, Supplier Invoices, Debit Notes, Payments with allocation, mirroring AR's posting pattern. - Tax Codes shared between AR and AP. - Company-app login is a token handoff from the Account Manager (RS256, single-use, company-scoped) rather than a local password. - Frontend reskinned to the approved mockup: navy sidebar, dashboard, tree-view Chart of Accounts, Customer/Supplier 360 pages. Account Manager (manager/, manager/client/) — central login gateway: - Password + TOTP 2FA + Google/Microsoft OAuth, company picker, and the RS256 handoff token issuance that the company app verifies. Covered by unit + integration tests against a real Postgres database (GL balance invariants, AR/AP posting correctness and allocation limits, the full handoff exchange including replay/cross-company/expiry/forgery rejection). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tq2DbEvzHzs13jDFhLaX44
83 lines
3.3 KiB
TypeScript
83 lines
3.3 KiB
TypeScript
/**
|
|
* Requires the real, migrated, seeded company-app database (see SETUP.md) reachable via
|
|
* DATABASE_URL, and the Manager's real handoff keypair at ../manager/keys/ (see
|
|
* manager/SETUP.md — npm run generate-handoff-keys). Run with: npm run test:integration
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import jwt from 'jsonwebtoken';
|
|
import crypto from 'crypto';
|
|
import request from 'supertest';
|
|
import { createApp } from '../../src/app';
|
|
import { prisma } from '../../src/lib/prisma';
|
|
|
|
const app = createApp();
|
|
const managerPrivateKey = fs.readFileSync(path.resolve(__dirname, '../../manager/keys/handoff-private.pem'), 'utf8');
|
|
|
|
const ADMIN_EMAIL = 'joosth2002@icloud.com';
|
|
|
|
function mintHandoffToken(overrides: Partial<{ sub: string; companySlug: string; expiresIn: number }> = {}) {
|
|
const claims = { sub: overrides.sub ?? ADMIN_EMAIL, pid: 1, cid: 1, companySlug: overrides.companySlug ?? 'demo' };
|
|
return jwt.sign(claims, managerPrivateKey, {
|
|
algorithm: 'RS256',
|
|
expiresIn: overrides.expiresIn ?? 90,
|
|
jwtid: crypto.randomUUID(),
|
|
});
|
|
}
|
|
|
|
describe('Company app — handoff token exchange (real DB, real keypair)', () => {
|
|
afterAll(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
it('exchanges a valid handoff token for a working session', async () => {
|
|
const token = mintHandoffToken();
|
|
const response = await request(app).post('/api/auth/handoff').send({ token });
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.accessToken).toBeDefined();
|
|
expect(response.body.user.email).toBe(ADMIN_EMAIL);
|
|
|
|
const me = await request(app).get('/api/auth/me').set('Authorization', `Bearer ${response.body.accessToken}`);
|
|
expect(me.status).toBe(200);
|
|
expect(me.body.user.email).toBe(ADMIN_EMAIL);
|
|
});
|
|
|
|
it('rejects the same token twice (single-use / replay protection)', async () => {
|
|
const token = mintHandoffToken();
|
|
const first = await request(app).post('/api/auth/handoff').send({ token });
|
|
expect(first.status).toBe(200);
|
|
|
|
const second = await request(app).post('/api/auth/handoff').send({ token });
|
|
expect(second.status).toBe(401);
|
|
expect(second.body.error).toMatch(/already been used/i);
|
|
});
|
|
|
|
it('rejects a token minted for a different company slug', async () => {
|
|
const token = mintHandoffToken({ companySlug: 'some-other-company' });
|
|
const response = await request(app).post('/api/auth/handoff').send({ token });
|
|
expect(response.status).toBe(401);
|
|
expect(response.body.error).toMatch(/not issued for this company/i);
|
|
});
|
|
|
|
it('rejects an expired token', async () => {
|
|
const token = mintHandoffToken({ expiresIn: -10 });
|
|
const response = await request(app).post('/api/auth/handoff').send({ token });
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
it('rejects a well-formed but unsigned/forged token', async () => {
|
|
const forged = jwt.sign({ sub: ADMIN_EMAIL, pid: 1, cid: 1, companySlug: 'demo' }, 'not-the-real-key', {
|
|
algorithm: 'HS256',
|
|
expiresIn: 90,
|
|
jwtid: crypto.randomUUID(),
|
|
});
|
|
const response = await request(app).post('/api/auth/handoff').send({ token: forged });
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
it('the old POST /api/auth/login route no longer exists', async () => {
|
|
const response = await request(app).post('/api/auth/login').send({ email: 'x', password: 'y' });
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|