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.
179 lines
7.9 KiB
TypeScript
179 lines
7.9 KiB
TypeScript
// apps/hq/test/shares.test.ts
|
|
import { describe, it, expect, beforeAll, afterAll } 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, validateShare, revokeShare, listShares } from '../src/repos-shares'
|
|
import { apiRouter } from '../src/api'
|
|
|
|
async function setup() {
|
|
const db = openDb(':memory:')
|
|
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
|
|
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
|
|
const c = await createClient(db, 'u1', { name: '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' }] })
|
|
return { db, c, m, doc }
|
|
}
|
|
|
|
describe('repos-shares (mint / validate / revoke / list)', () => {
|
|
it('mintShare produces a 64-hex token, unique per mint, with a +30d default expiry', async () => {
|
|
const { db, doc } = await setup()
|
|
const s1 = await mintShare(db, 'u1', doc.id)
|
|
const s2 = await mintShare(db, 'u1', doc.id)
|
|
expect(s1.token).toMatch(/^[0-9a-f]{64}$/)
|
|
expect(s2.token).toMatch(/^[0-9a-f]{64}$/)
|
|
expect(s1.token).not.toBe(s2.token) // unique
|
|
expect(s1.revoked).toBe(false)
|
|
expect(s1.expiresAt).not.toBeNull()
|
|
// default is +30 days from now
|
|
const days = (new Date(s1.expiresAt!).getTime() - Date.now()) / 86_400_000
|
|
expect(days).toBeGreaterThan(29)
|
|
expect(days).toBeLessThan(31)
|
|
})
|
|
|
|
it('mintShare writes an audit row that NEVER contains the token (secrets never logged)', async () => {
|
|
const { db, doc } = await setup()
|
|
const s = await mintShare(db, 'u1', doc.id)
|
|
const rows = await db.all<{ user_id: string; entity_id: string; before_json: string | null; after_json: string | null }>(
|
|
`SELECT * FROM audit_log WHERE entity='document_share' AND action='create'`,
|
|
)
|
|
expect(rows.length).toBe(1)
|
|
expect(rows[0]!.user_id).toBe('u1')
|
|
expect(rows[0]!.entity_id).toBe(s.id)
|
|
// The token must not leak into the audit trail (any column).
|
|
expect(JSON.stringify(rows[0])).not.toContain(s.token)
|
|
})
|
|
|
|
it('mintShare throws for an unknown document', async () => {
|
|
const { db } = await setup()
|
|
await expect(mintShare(db, 'u1', 'nope')).rejects.toThrow(/not found/i)
|
|
})
|
|
|
|
it('validateShare returns the one document for a live token', async () => {
|
|
const { db, doc } = await setup()
|
|
const s = await mintShare(db, 'u1', doc.id)
|
|
const out = await validateShare(db, s.token)
|
|
expect(out).not.toBeNull()
|
|
expect(out!.id).toBe(doc.id)
|
|
expect(out!.payablePaise).toBe(11_800_00)
|
|
})
|
|
|
|
it('validateShare returns null for an unknown token', async () => {
|
|
const { db } = await setup()
|
|
expect(await validateShare(db, 'deadbeef')).toBeNull()
|
|
})
|
|
|
|
it('validateShare returns null for an expired token', async () => {
|
|
const { db, doc } = await setup()
|
|
const s = await mintShare(db, 'u1', doc.id, { expiresDays: -1 }) // already expired
|
|
expect(s.expiresAt).not.toBeNull()
|
|
expect(await validateShare(db, s.token)).toBeNull()
|
|
})
|
|
|
|
it('validateShare returns null for a revoked token', async () => {
|
|
const { db, doc } = await setup()
|
|
const s = await mintShare(db, 'u1', doc.id)
|
|
await revokeShare(db, 'u1', s.id)
|
|
expect(await validateShare(db, s.token)).toBeNull()
|
|
})
|
|
|
|
it('revokeShare flips revoked, writes an audit row, and is idempotent-safe', async () => {
|
|
const { db, doc } = await setup()
|
|
const s = await mintShare(db, 'u1', doc.id)
|
|
const after = await revokeShare(db, 'u1', s.id)
|
|
expect(after.revoked).toBe(true)
|
|
const rows = await db.all(
|
|
`SELECT * FROM audit_log WHERE entity='document_share' AND action='revoke'`,
|
|
)
|
|
expect(rows.length).toBe(1)
|
|
})
|
|
|
|
it('revokeShare throws for an unknown share', async () => {
|
|
const { db } = await setup()
|
|
await expect(revokeShare(db, 'u1', 'nope')).rejects.toThrow(/not found/i)
|
|
})
|
|
|
|
it('listShares returns every share for the document (scoped to that doc)', async () => {
|
|
const { db, doc, m, c } = await setup()
|
|
const s1 = await mintShare(db, 'u1', doc.id)
|
|
const s2 = await mintShare(db, 'u1', doc.id)
|
|
// A share on a different document must not leak into this list.
|
|
const other = await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id,
|
|
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
|
|
await mintShare(db, 'u1', other.id)
|
|
const list = await listShares(db, doc.id)
|
|
expect(list.length).toBe(2)
|
|
expect(new Set(list.map((s) => s.id))).toEqual(new Set([s1.id, s2.id]))
|
|
})
|
|
})
|
|
|
|
async function appWith() {
|
|
const db = openDb(':memory:'); await seedIfEmpty(db) // company.state_code=32, GST18
|
|
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
|
|
const c = await createClient(db, 'u1', { name: 'Acme', 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 app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
|
|
const server = app.listen(0)
|
|
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
|
return { db, server, base, doc }
|
|
}
|
|
|
|
describe('share routes (requireAuth)', () => {
|
|
let ctx: Awaited<ReturnType<typeof appWith>>
|
|
beforeAll(async () => { ctx = await appWith() })
|
|
afterAll(() => ctx.server.close())
|
|
const login = async () =>
|
|
(await (await fetch(`${ctx.base}/auth/login`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }),
|
|
})).json() as any).token
|
|
const post = async (path: string, token: string | null, body?: unknown) => {
|
|
const res = await fetch(`${ctx.base}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
body: JSON.stringify(body ?? {}),
|
|
})
|
|
return { status: res.status, json: await res.json() as any }
|
|
}
|
|
|
|
it('POST /documents/:id/share requires auth', async () => {
|
|
expect((await post(`/documents/${ctx.doc.id}/share`, null)).status).toBe(401)
|
|
})
|
|
|
|
it('POST /documents/:id/share mints a share with a 64-hex token', async () => {
|
|
const token = await login()
|
|
const out = await post(`/documents/${ctx.doc.id}/share`, token)
|
|
expect(out.status).toBe(200)
|
|
expect(out.json.ok).toBe(true)
|
|
expect(out.json.share.token).toMatch(/^[0-9a-f]{64}$/)
|
|
expect(out.json.share.revoked).toBe(false)
|
|
})
|
|
|
|
it('POST /documents/:id/share 404s for an unknown document', async () => {
|
|
const token = await login()
|
|
const out = await post(`/documents/nope/share`, token)
|
|
expect(out.status).toBe(404)
|
|
})
|
|
|
|
it('POST /documents/:id/share/:shareId/revoke revokes the share', async () => {
|
|
const token = await login()
|
|
const minted = await post(`/documents/${ctx.doc.id}/share`, token)
|
|
const shareId = minted.json.share.id
|
|
const out = await post(`/documents/${ctx.doc.id}/share/${shareId}/revoke`, token)
|
|
expect(out.status).toBe(200)
|
|
expect(out.json.ok).toBe(true)
|
|
// the token no longer resolves to a document
|
|
expect(await validateShare(ctx.db, minted.json.share.token)).toBeNull()
|
|
})
|
|
})
|