You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
111 lines
5.1 KiB
TypeScript
111 lines
5.1 KiB
TypeScript
// apps/hq/test/public-share.test.ts
|
|
import { describe, it, expect } from 'vitest'
|
|
import express from 'express'
|
|
import { openDb } from '../src/db'
|
|
import { seedIfEmpty } from '../src/seed'
|
|
import { createStaff } from '../src/auth'
|
|
import { createClient } from '../src/repos-clients'
|
|
import { createModule, setPrice } from '../src/repos-modules'
|
|
import { createDraft } from '../src/repos-documents'
|
|
import { mintShare, revokeShare } from '../src/repos-shares'
|
|
import { mountPublicShare } from '../src/server'
|
|
|
|
/**
|
|
* The PUBLIC read route GET /share/:token — mounted OUTSIDE the /api auth router.
|
|
* A live token renders exactly one document via the existing documentHtml/renderPdf
|
|
* path and returns it inline (application/pdf); unknown/expired/revoked → a plain 404
|
|
* HTML page. No auth header is ever required, and lookups are rate-limited per IP.
|
|
*
|
|
* renderPdf is injected (a fake %PDF- buffer) so the route logic is exercised without
|
|
* launching Chrome — the house pattern for every PDF-touching test in this suite.
|
|
*/
|
|
async function setup(rateLimit?: { limit: number; windowMs: number }) {
|
|
const db = openDb(':memory:'); await seedIfEmpty(db)
|
|
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
|
|
const c = await createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' })
|
|
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
|
|
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
|
|
const doc = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
const rendered: string[] = []
|
|
const fakePdf = async (html: string): Promise<Buffer> => { rendered.push(html); return Buffer.from('%PDF-1.4 fake') }
|
|
const app = express(); app.use(express.json()); app.locals['db'] = db
|
|
const deps: Parameters<typeof mountPublicShare>[2] = { renderPdf: fakePdf }
|
|
if (rateLimit !== undefined) deps.rateLimit = rateLimit
|
|
mountPublicShare(app, db, deps)
|
|
const server = app.listen(0)
|
|
const base = `http://localhost:${(server.address() as { port: number }).port}`
|
|
return { db, server, base, c, m, doc, rendered }
|
|
}
|
|
|
|
describe('public GET /share/:token', () => {
|
|
it('serves the one document inline as a PDF for a live token — with NO auth header', async () => {
|
|
const ctx = await setup()
|
|
try {
|
|
const s = await mintShare(ctx.db, 'u1', ctx.doc.id)
|
|
const res = await fetch(`${ctx.base}/share/${s.token}`) // deliberately no authorization header
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('content-type')).toContain('application/pdf')
|
|
const buf = Buffer.from(await res.arrayBuffer())
|
|
expect(buf.subarray(0, 5).toString()).toBe('%PDF-')
|
|
// Rendered exactly one document, and it was THIS document (its client is on the letterhead).
|
|
expect(ctx.rendered.length).toBe(1)
|
|
expect(ctx.rendered[0]).toContain('Acme Traders')
|
|
} finally { ctx.server.close() }
|
|
})
|
|
|
|
it('serves the PDF inline (Content-Disposition inline, not attachment)', async () => {
|
|
const ctx = await setup()
|
|
try {
|
|
const s = await mintShare(ctx.db, 'u1', ctx.doc.id)
|
|
const res = await fetch(`${ctx.base}/share/${s.token}`)
|
|
expect(res.headers.get('content-disposition')).toMatch(/^inline/)
|
|
} finally { ctx.server.close() }
|
|
})
|
|
|
|
it('404s with a plain HTML "expired or invalid" page for an unknown token — renders nothing', async () => {
|
|
const ctx = await setup()
|
|
try {
|
|
const res = await fetch(`${ctx.base}/share/deadbeefdeadbeef`)
|
|
expect(res.status).toBe(404)
|
|
expect(res.headers.get('content-type')).toContain('text/html')
|
|
const body = (await res.text()).toLowerCase()
|
|
expect(body).toMatch(/expired|invalid/)
|
|
expect(ctx.rendered.length).toBe(0) // a miss never touches the renderer
|
|
} finally { ctx.server.close() }
|
|
})
|
|
|
|
it('404s for an expired token', async () => {
|
|
const ctx = await setup()
|
|
try {
|
|
const s = await mintShare(ctx.db, 'u1', ctx.doc.id, { expiresDays: -1 }) // already expired
|
|
const res = await fetch(`${ctx.base}/share/${s.token}`)
|
|
expect(res.status).toBe(404)
|
|
expect(ctx.rendered.length).toBe(0)
|
|
} finally { ctx.server.close() }
|
|
})
|
|
|
|
it('404s for a revoked token', async () => {
|
|
const ctx = await setup()
|
|
try {
|
|
const s = await mintShare(ctx.db, 'u1', ctx.doc.id)
|
|
await revokeShare(ctx.db, 'u1', s.id)
|
|
const res = await fetch(`${ctx.base}/share/${s.token}`)
|
|
expect(res.status).toBe(404)
|
|
expect(ctx.rendered.length).toBe(0)
|
|
} finally { ctx.server.close() }
|
|
})
|
|
|
|
it('rate-limits lookups per IP to blunt token brute-forcing', async () => {
|
|
const ctx = await setup({ limit: 3, windowMs: 60_000 })
|
|
try {
|
|
for (let i = 0; i < 3; i++) {
|
|
const r = await fetch(`${ctx.base}/share/badtoken${i}`)
|
|
expect(r.status).toBe(404) // allowed (though a miss), counts against the cap
|
|
}
|
|
const blocked = await fetch(`${ctx.base}/share/badtoken-final`)
|
|
expect(blocked.status).toBe(429)
|
|
} finally { ctx.server.close() }
|
|
})
|
|
})
|