326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import request from 'supertest';
|
||
import { totp } from '../src/accounts';
|
||
import { buildApp } from '../src/app';
|
||
import { PaymentClient } from '../src/portal';
|
||
|
||
const devPayments: PaymentClient = {
|
||
reload: async (_customer, amountCents) => ({ mode: 'dev' as const, creditedCents: amountCents }),
|
||
};
|
||
|
||
function portalApp() {
|
||
return buildApp({
|
||
payments: devPayments,
|
||
qr: async (uri) => `data:image/png;base64,fake-qr-for:${uri}`,
|
||
});
|
||
}
|
||
|
||
async function signup(app: ReturnType<typeof buildApp>['app'], email = 'ada@example.com') {
|
||
const res = await request(app)
|
||
.post('/portal/api/signup')
|
||
.send({ name: 'Ada', email, password: 'super-secret-1' });
|
||
expect(res.status).toBe(201);
|
||
return res.body as { token: string; customer: { id: string; apiKey: string } };
|
||
}
|
||
|
||
describe('portal signup + login', () => {
|
||
it('signs up a new customer on the free tier with an API key', async () => {
|
||
const { app, customers } = portalApp();
|
||
const { token, customer } = await signup(app);
|
||
expect(customer.apiKey).toMatch(/^key-/);
|
||
const stored = customers.findByEmail('ada@example.com');
|
||
expect(stored?.tierId).toBe('free');
|
||
expect(stored?.passwordHash).toMatch(/^scrypt:/);
|
||
const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`);
|
||
expect(me.status).toBe(200);
|
||
expect(me.body.tierId).toBe('free');
|
||
expect(me.body.balanceCents).toBe(0);
|
||
});
|
||
|
||
it('never leaks passwordHash or totpSecret through the API', async () => {
|
||
const { app } = portalApp();
|
||
const { token } = await signup(app);
|
||
const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`);
|
||
expect(me.body.passwordHash).toBeUndefined();
|
||
expect(me.body.totpSecret).toBeUndefined();
|
||
});
|
||
|
||
it('rejects weak passwords and duplicate emails', async () => {
|
||
const { app } = portalApp();
|
||
const weak = await request(app)
|
||
.post('/portal/api/signup')
|
||
.send({ name: 'Ada', email: 'ada@example.com', password: 'short' });
|
||
expect(weak.status).toBe(400);
|
||
await signup(app);
|
||
const dup = await request(app)
|
||
.post('/portal/api/signup')
|
||
.send({ name: 'Other', email: 'ada@example.com', password: 'super-secret-2' });
|
||
expect(dup.status).toBe(409);
|
||
});
|
||
|
||
it('claims an admin-created customer by email on first signup', async () => {
|
||
const { app, customers } = portalApp();
|
||
const ada = customers.findByApiKey('key-ada')!;
|
||
customers.save({ ...ada, email: 'ada@example.com' });
|
||
const { customer } = await signup(app);
|
||
expect(customer.id).toBe('cust_1'); // same customer, now with a password
|
||
expect(customers.findByApiKey('key-ada')?.passwordHash).toMatch(/^scrypt:/);
|
||
});
|
||
|
||
it('logs in with email + password and rejects bad credentials', async () => {
|
||
const { app } = portalApp();
|
||
await signup(app);
|
||
const bad = await request(app)
|
||
.post('/portal/api/login')
|
||
.send({ email: 'ada@example.com', password: 'wrong-password' });
|
||
expect(bad.status).toBe(401);
|
||
const ok = await request(app)
|
||
.post('/portal/api/login')
|
||
.send({ email: 'ada@example.com', password: 'super-secret-1' });
|
||
expect(ok.status).toBe(200);
|
||
expect(ok.body.token).toBeTruthy();
|
||
});
|
||
|
||
it('requires a session for /me (401 without token)', async () => {
|
||
const { app } = portalApp();
|
||
expect((await request(app).get('/portal/api/me')).status).toBe(401);
|
||
});
|
||
});
|
||
|
||
describe('portal scoping', () => {
|
||
it('lists only the signed-in customer’s invoices', async () => {
|
||
const { app, customers, invoices } = portalApp();
|
||
const { token, customer } = await signup(app);
|
||
invoices.save({
|
||
id: 'INV-2026-07-0001',
|
||
customerId: customer.id,
|
||
period: '2026-07',
|
||
status: 'issued',
|
||
lines: [{ endpointId: 'transform', calls: 10, cents: 30 }],
|
||
totalCents: 30,
|
||
creditCents: 0,
|
||
billableCents: 30,
|
||
billingType: 'stripe',
|
||
});
|
||
invoices.save({
|
||
id: 'INV-2026-07-0002',
|
||
customerId: 'cust_2',
|
||
period: '2026-07',
|
||
status: 'issued',
|
||
lines: [],
|
||
totalCents: 999,
|
||
creditCents: 0,
|
||
billableCents: 999,
|
||
billingType: 'stripe',
|
||
});
|
||
const res = await request(app)
|
||
.get('/portal/api/invoices')
|
||
.set('authorization', `Bearer ${token}`);
|
||
expect(res.body.invoices.map((i: { id: string }) => i.id)).toEqual(['INV-2026-07-0001']);
|
||
expect(customers.list()).toHaveLength(4); // 3 seeds + signup
|
||
});
|
||
|
||
it('returns 404 for another customer’s invoice (json and html)', async () => {
|
||
const { app, invoices } = portalApp();
|
||
const { token } = await signup(app);
|
||
invoices.save({
|
||
id: 'INV-2026-07-0009',
|
||
customerId: 'cust_2',
|
||
period: '2026-07',
|
||
status: 'issued',
|
||
lines: [],
|
||
totalCents: 999,
|
||
creditCents: 0,
|
||
billableCents: 999,
|
||
billingType: 'stripe',
|
||
});
|
||
const json = await request(app)
|
||
.get('/portal/api/invoices/INV-2026-07-0009')
|
||
.set('authorization', `Bearer ${token}`);
|
||
expect(json.status).toBe(404);
|
||
const html = await request(app)
|
||
.get('/portal/api/invoices/INV-2026-07-0009?format=html')
|
||
.set('authorization', `Bearer ${token}`);
|
||
expect(html.status).toBe(404);
|
||
});
|
||
|
||
it('serves the customer’s own invoice as printable HTML', async () => {
|
||
const { app, invoices } = portalApp();
|
||
const { token, customer } = await signup(app);
|
||
invoices.save({
|
||
id: 'INV-2026-07-0001',
|
||
customerId: customer.id,
|
||
period: '2026-07',
|
||
status: 'issued',
|
||
lines: [{ endpointId: 'transform', calls: 10, cents: 30 }],
|
||
totalCents: 30,
|
||
creditCents: 0,
|
||
billableCents: 30,
|
||
billingType: 'stripe',
|
||
});
|
||
const res = await request(app)
|
||
.get('/portal/api/invoices/INV-2026-07-0001?format=html')
|
||
.set('authorization', `Bearer ${token}`);
|
||
expect(res.status).toBe(200);
|
||
expect(res.text).toContain('INV-2026-07-0001');
|
||
});
|
||
});
|
||
|
||
describe('portal api key + usage', () => {
|
||
it('regenerates the API key; the old key stops working', async () => {
|
||
const { app } = portalApp();
|
||
const { token, customer } = await signup(app);
|
||
const oldKey = customer.apiKey;
|
||
const res = await request(app)
|
||
.post('/portal/api/api-key')
|
||
.set('authorization', `Bearer ${token}`);
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.apiKey).not.toBe(oldKey);
|
||
const stale = await request(app).get('/v1/status').set('x-api-key', oldKey);
|
||
expect(stale.status).toBe(401);
|
||
const fresh = await request(app).get('/v1/status').set('x-api-key', res.body.apiKey);
|
||
expect(fresh.status).toBe(200);
|
||
});
|
||
|
||
it('reports month-to-date usage with the tier credit applied', async () => {
|
||
const { app, usage, customers } = portalApp();
|
||
const { token, customer } = await signup(app);
|
||
usage.record({
|
||
customerId: customer.id,
|
||
endpointId: 'transform',
|
||
cents: 40,
|
||
metadataBytes: 0,
|
||
attachmentBytes: 0,
|
||
timestamp: new Date(),
|
||
});
|
||
const res = await request(app).get('/portal/api/usage').set('authorization', `Bearer ${token}`);
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.totalCents).toBe(40);
|
||
expect(res.body.includedCents).toBe(40); // free tier credit 100 covers it
|
||
expect(res.body.billableCents).toBe(0);
|
||
expect(customers.findByEmail('ada@example.com')).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
describe('portal 2FA', () => {
|
||
it('setup → enable → login requires the TOTP code', async () => {
|
||
const { app, customers } = portalApp();
|
||
const { token } = await signup(app);
|
||
const setup = await request(app)
|
||
.post('/portal/api/2fa/setup')
|
||
.set('authorization', `Bearer ${token}`);
|
||
expect(setup.status).toBe(200);
|
||
expect(setup.body.secret).toMatch(/^[A-Z2-7]{32}$/);
|
||
expect(setup.body.uri).toContain('otpauth://totp/');
|
||
expect(setup.body.qr).toMatch(/^data:image\/png;base64,/);
|
||
|
||
// Enabling with a wrong code fails.
|
||
const badEnable = await request(app)
|
||
.post('/portal/api/2fa/enable')
|
||
.set('authorization', `Bearer ${token}`)
|
||
.send({ code: '000000' });
|
||
expect(badEnable.status).toBe(400);
|
||
|
||
const code = totp(setup.body.secret, Date.now());
|
||
const enable = await request(app)
|
||
.post('/portal/api/2fa/enable')
|
||
.set('authorization', `Bearer ${token}`)
|
||
.send({ code });
|
||
expect(enable.status).toBe(200);
|
||
expect(customers.findByEmail('ada@example.com')?.totpEnabled).toBe(true);
|
||
|
||
// Password alone no longer suffices.
|
||
const noCode = await request(app)
|
||
.post('/portal/api/login')
|
||
.send({ email: 'ada@example.com', password: 'super-secret-1' });
|
||
expect(noCode.status).toBe(401);
|
||
expect(noCode.body.error).toBe('totp_required');
|
||
|
||
const withCode = await request(app)
|
||
.post('/portal/api/login')
|
||
.send({
|
||
email: 'ada@example.com',
|
||
password: 'super-secret-1',
|
||
totpCode: totp(setup.body.secret, Date.now()),
|
||
});
|
||
expect(withCode.status).toBe(200);
|
||
});
|
||
|
||
it('disables 2FA with a valid code', async () => {
|
||
const { app, customers } = portalApp();
|
||
const { token } = await signup(app);
|
||
const setup = await request(app)
|
||
.post('/portal/api/2fa/setup')
|
||
.set('authorization', `Bearer ${token}`);
|
||
await request(app)
|
||
.post('/portal/api/2fa/enable')
|
||
.set('authorization', `Bearer ${token}`)
|
||
.send({ code: totp(setup.body.secret, Date.now()) });
|
||
const disable = await request(app)
|
||
.post('/portal/api/2fa/disable')
|
||
.set('authorization', `Bearer ${token}`)
|
||
.send({ code: totp(setup.body.secret, Date.now()) });
|
||
expect(disable.status).toBe(200);
|
||
const stored = customers.findByEmail('ada@example.com');
|
||
expect(stored?.totpEnabled).toBe(false);
|
||
expect(stored?.totpSecret).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('portal billing', () => {
|
||
it('reloads the prepaid balance via the payment client', async () => {
|
||
const { app } = portalApp();
|
||
const { token } = await signup(app);
|
||
const res = await request(app)
|
||
.post('/portal/api/reload')
|
||
.set('authorization', `Bearer ${token}`)
|
||
.send({ amountCents: 2500 });
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.balanceCents).toBe(2500);
|
||
expect(res.body.mode).toBe('dev');
|
||
const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`);
|
||
expect(me.body.balanceCents).toBe(2500);
|
||
const st = await request(app).get('/portal/api/statement').set('authorization', `Bearer ${token}`);
|
||
expect(st.status).toBe(200);
|
||
expect(st.body.prepaidCents).toBe(2500);
|
||
expect(Array.isArray(st.body.credits)).toBe(true);
|
||
expect(Array.isArray(st.body.usage)).toBe(true);
|
||
expect(Array.isArray(st.body.payments)).toBe(true);
|
||
});
|
||
|
||
it('rejects out-of-range reload amounts', async () => {
|
||
const { app } = portalApp();
|
||
const { token } = await signup(app);
|
||
for (const amountCents of [0, 50, -100, 2_000_000, 12.5]) {
|
||
const res = await request(app)
|
||
.post('/portal/api/reload')
|
||
.set('authorization', `Bearer ${token}`)
|
||
.send({ amountCents });
|
||
expect(res.status).toBe(400);
|
||
}
|
||
});
|
||
|
||
it('serves the live rate card and tiers for the pricing page', async () => {
|
||
const { app } = portalApp();
|
||
const { token } = await signup(app);
|
||
const res = await request(app)
|
||
.get('/portal/api/pricing')
|
||
.set('authorization', `Bearer ${token}`);
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.rateCard.endpoints.transform).toBeTruthy();
|
||
expect(res.body.tiers.map((t: { id: string }) => t.id)).toEqual(
|
||
expect.arrayContaining(['free', 'pro', 'business']),
|
||
);
|
||
});
|
||
|
||
it('toggles email invoicing', async () => {
|
||
const { app } = portalApp();
|
||
const { token } = await signup(app);
|
||
const res = await request(app)
|
||
.put('/portal/api/email-invoicing')
|
||
.set('authorization', `Bearer ${token}`)
|
||
.send({ enabled: true });
|
||
expect(res.status).toBe(200);
|
||
const me = await request(app).get('/portal/api/me').set('authorization', `Bearer ${token}`);
|
||
expect(me.body.emailInvoicing).toBe(true);
|
||
});
|
||
});
|