import { describe, it, expect } from 'vitest' import { SIGNED_TTL_MS, signPath, verifyPath, buildSignedUrl, signOwnUpload, verifyOwnUpload, buildOwnUploadUrl, } from '../../skills/data-portal/template/functions/api/_lib/portal-sign.mjs' const SECRET = 'test-secret-value' const ACC = '098a18a3-1741-447f-9778-65470645d57d' const NOW = 1_800_000_000_000 describe('portal-sign', () => { it('round-trips a signature', async () => { const exp = NOW + SIGNED_TTL_MS const sig = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp) expect(await verifyPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp, sig, NOW)).toBe(true) }) it('refuses an expired signature', async () => { const exp = NOW - 1 const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp) expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp, sig, NOW)).toBe(false) }) it('refuses a tampered path', async () => { const exp = NOW + SIGNED_TTL_MS const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp) expect(await verifyPath(SECRET, ACC, 'documents/private.pdf', exp, sig, NOW)).toBe(false) }) it('refuses a tampered account', async () => { const exp = NOW + SIGNED_TTL_MS const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp) expect(await verifyPath(SECRET, 'other-account', 'output/a.pdf', exp, sig, NOW)).toBe(false) }) it('refuses a tampered expiry', async () => { const exp = NOW + SIGNED_TTL_MS const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp) expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp + 60_000, sig, NOW)).toBe(false) }) it('refuses a signature made with a different secret', async () => { const exp = NOW + SIGNED_TTL_MS const sig = await signPath('other-secret', ACC, 'output/a.pdf', exp) expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp, sig, NOW)).toBe(false) }) // The field separator must be load-bearing: without it, an accountId that // absorbs the start of a path could forge a different (account, path) pair. it('does not confuse account and path across the separator', async () => { const exp = NOW + SIGNED_TTL_MS const sig = await signPath(SECRET, 'acc', 'a/b.pdf', exp) expect(await verifyPath(SECRET, 'acc\na', '/b.pdf', exp, sig, NOW)).toBe(false) }) it('builds a url carrying account, path, expiry and signature', async () => { const url = new URL( await buildSignedUrl('https://install.example', SECRET, ACC, 'output/a b.pdf', NOW + 1000), ) expect(url.pathname).toBe('/api/portal/fetch') expect(url.searchParams.get('accountId')).toBe(ACC) expect(url.searchParams.get('path')).toBe('output/a b.pdf') expect(url.searchParams.get('exp')).toBe(String(NOW + 1000)) expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/) }) }) // The second link type (Task 1910). An untargeted upload moves to // `uploads//`, which the exposed set never contains, so the ordinary // signature cannot reach it and the device has no way to know whose folder is // being asked for. Binding the owner INSIDE the HMAC is what makes that safe. describe('own-upload signatures', () => { const OWNER = 'alice' const PATH = 'uploads/alice/invoice.pdf' it('verifies a link minted for the same owner', async () => { const exp = NOW + 1000 const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp) expect(await verifyOwnUpload(SECRET, ACC, OWNER, PATH, exp, sig, NOW)).toBe(true) }) it('refuses the same path claimed under a different owner', async () => { // The whole point: bob must not be able to present alice's link, and a // portal must not be able to mint one into her folder. const exp = NOW + 1000 const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp) expect(await verifyOwnUpload(SECRET, ACC, 'bob', PATH, exp, sig, NOW)).toBe(false) }) it('is a DIFFERENT message from the exposed-folder signature', async () => { // If the two shapes collided, an ordinary signed link would admit an // uploads path and the owner binding would be decorative. const exp = NOW + 1000 const own = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp) const plain = await signPath(SECRET, ACC, PATH, exp) expect(own).not.toBe(plain) }) it('does not verify under the ordinary scheme', async () => { const exp = NOW + 1000 const own = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp) expect(await verifyPath(SECRET, ACC, PATH, exp, own, NOW)).toBe(false) }) it('refuses an expired link', async () => { const exp = NOW - 1 const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp) expect(await verifyOwnUpload(SECRET, ACC, OWNER, PATH, exp, sig, NOW)).toBe(false) }) it('refuses a wrong secret', async () => { const exp = NOW + 1000 const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp) expect(await verifyOwnUpload('other-secret', ACC, OWNER, PATH, exp, sig, NOW)).toBe(false) }) it('carries the owner as a query parameter so the device can name it', async () => { const raw = await buildOwnUploadUrl('https://install.example', SECRET, ACC, OWNER, PATH, NOW + 1000) const url = new URL(raw) expect(url.pathname).toBe('/api/portal/fetch') expect(url.searchParams.get('owner')).toBe(OWNER) expect(url.searchParams.get('path')).toBe(PATH) expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/) }) })