feat(hq): public GET /share/:token document view route
Mount the one unauthenticated read route OUTSIDE the /api auth router. A live token resolves via validateShare to exactly one document, rendered inline (application/pdf) through the existing documentHtml -> renderPdf path; unknown/expired/revoked tokens return a plain 404 "link expired or invalid" HTML page carrying no document data. Adds a dependency-free per-IP fixed-window rate limiter to blunt token brute-forcing. renderPdf is injected so the route is TDD-tested without launching Chrome. Public reads write no audit; the token is never logged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
a1759864bf
commit
63e377d649
@ -0,0 +1,110 @@
|
|||||||
|
// 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.
|
||||||
|
*/
|
||||||
|
function setup(rateLimit?: { limit: number; windowMs: number }) {
|
||||||
|
const db = openDb(':memory:'); seedIfEmpty(db)
|
||||||
|
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
|
||||||
|
const c = createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' })
|
||||||
|
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
|
||||||
|
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
|
||||||
|
const doc = 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 = setup()
|
||||||
|
try {
|
||||||
|
const s = 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 = setup()
|
||||||
|
try {
|
||||||
|
const s = 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 = 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 = setup()
|
||||||
|
try {
|
||||||
|
const s = 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 = setup()
|
||||||
|
try {
|
||||||
|
const s = mintShare(ctx.db, 'u1', ctx.doc.id)
|
||||||
|
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 = 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() }
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue