zappier-edge/tests/portal.test.ts

326 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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