|
| 1 | +import { createHmac } from 'node:crypto' |
| 2 | + |
| 3 | +import { afterEach, describe, expect, it, vi } from 'vitest' |
| 4 | + |
| 5 | +import { getSignedSmartCdnUrl, signParams } from '../src/index.ts' |
| 6 | +import { getSignedSmartCdnUrl as getSignedSmartCdnUrlSync, signParamsSync } from '../src/node.ts' |
| 7 | + |
| 8 | +const options = { |
| 9 | + authKey: 'test-key', |
| 10 | + authSecret: 'test-secret', |
| 11 | + expiresAt: 1_900_000_000_000, |
| 12 | + input: 'https://assets.example/image.jpg?version=1', |
| 13 | + template: 'builtin/serve-image@0.0.1', |
| 14 | + urlParams: { f: ['avif', 'webp'], fit: true, q: 75, w: 640 }, |
| 15 | + workspace: 'test-workspace', |
| 16 | +} |
| 17 | + |
| 18 | +// Computed once with the Node signer; guards both signers against drifting together. |
| 19 | +const knownAnswer = |
| 20 | + 'https://test-workspace.tlcdn.com/builtin%2Fserve-image%400.0.1/' + |
| 21 | + 'https%3A%2F%2Fassets.example%2Fimage.jpg%3Fversion%3D1' + |
| 22 | + '?auth_key=test-key&exp=1900000000000&f=avif&f=webp&fit=true&q=75&w=640' + |
| 23 | + '&sig=sha256%3A69e40bc3a447c121a08d059ad9093a39c1beaf5c107a82992d5e78e0d9686f6b' |
| 24 | + |
| 25 | +afterEach(() => { |
| 26 | + vi.restoreAllMocks() |
| 27 | + vi.unstubAllGlobals() |
| 28 | +}) |
| 29 | + |
| 30 | +describe('getSignedSmartCdnUrl', () => { |
| 31 | + it('matches the known answer in both the WebCrypto and the Node signer', async () => { |
| 32 | + expect(getSignedSmartCdnUrlSync(options)).toBe(knownAnswer) |
| 33 | + await expect(getSignedSmartCdnUrl(options)).resolves.toBe(knownAnswer) |
| 34 | + }) |
| 35 | + |
| 36 | + it('signs the string to sign with HMAC-SHA256 over the sorted query', async () => { |
| 37 | + const stringToSign = |
| 38 | + 'test-workspace/builtin%2Fserve-image%400.0.1/' + |
| 39 | + 'https%3A%2F%2Fassets.example%2Fimage.jpg%3Fversion%3D1' + |
| 40 | + '?auth_key=test-key&exp=1900000000000&f=avif&f=webp&fit=true&q=75&w=640' |
| 41 | + const expected = createHmac('sha256', options.authSecret).update(stringToSign).digest('hex') |
| 42 | + |
| 43 | + const url = new URL(await getSignedSmartCdnUrl(options)) |
| 44 | + expect(url.searchParams.get('sig')).toBe(`sha256:${expected}`) |
| 45 | + }) |
| 46 | + |
| 47 | + it('defaults the expiry to one hour from now in both signers', async () => { |
| 48 | + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) |
| 49 | + const { expiresAt: _expiresAt, ...withoutExpiry } = options |
| 50 | + |
| 51 | + const syncUrl = getSignedSmartCdnUrlSync(withoutExpiry) |
| 52 | + const asyncUrl = await getSignedSmartCdnUrl(withoutExpiry) |
| 53 | + |
| 54 | + expect(new URL(syncUrl).searchParams.get('exp')).toBe(`${1_700_000_000_000 + 60 * 60 * 1000}`) |
| 55 | + expect(asyncUrl).toBe(syncUrl) |
| 56 | + }) |
| 57 | + |
| 58 | + it('rejects incomplete options the same way in both signers', async () => { |
| 59 | + for (const [key, message] of [ |
| 60 | + ['workspace', 'workspace is required'], |
| 61 | + ['template', 'template is required'], |
| 62 | + ['input', 'input is required'], |
| 63 | + ] as const) { |
| 64 | + const broken = { ...options, [key]: undefined } as unknown as typeof options |
| 65 | + expect(() => getSignedSmartCdnUrlSync(broken)).toThrow(new TypeError(message)) |
| 66 | + await expect(getSignedSmartCdnUrl(broken)).rejects.toThrow(new TypeError(message)) |
| 67 | + } |
| 68 | + }) |
| 69 | + |
| 70 | + it('explains that WebCrypto needs a secure origin when crypto.subtle is missing', async () => { |
| 71 | + vi.stubGlobal('crypto', undefined) |
| 72 | + |
| 73 | + await expect(getSignedSmartCdnUrl(options)).rejects.toThrow( |
| 74 | + 'Web Crypto is required to sign Transloadit payloads; browsers only provide crypto.subtle on secure origins (https:// or localhost)', |
| 75 | + ) |
| 76 | + }) |
| 77 | +}) |
| 78 | + |
| 79 | +describe('signParams', () => { |
| 80 | + it('produces the same signature as signParamsSync for every supported algorithm', async () => { |
| 81 | + const paramsString = JSON.stringify({ auth: { key: 'test-key' }, steps: {} }) |
| 82 | + |
| 83 | + for (const algorithm of ['sha1', 'sha256', 'sha384', 'sha512'] as const) { |
| 84 | + const expected = signParamsSync(paramsString, options.authSecret, algorithm) |
| 85 | + expect(expected.startsWith(`${algorithm}:`)).toBe(true) |
| 86 | + await expect(signParams(paramsString, options.authSecret, algorithm)).resolves.toBe(expected) |
| 87 | + } |
| 88 | + }) |
| 89 | +}) |
0 commit comments