diff --git a/README.md b/README.md index 92a88255..22a41cf6 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,22 @@ RPC_URL= NEXT_PUBLIC_TURNKEY_API_BASE_URL="https://api.turnkey.com" NEXT_PUBLIC_RPID="localhost" NEXT_PUBLIC_GA_API= + +# Vehicle templates. Both are server-only -- no NEXT_PUBLIC_ prefix, ever. +DEFINITIONS_WRITE_TOKEN= +DIMO_CURATOR_ADDRESSES= ``` Make sure that the `NEXT_PUBLIC_GA_API` maps to your [Accounts API](https://github.com/DIMO-Network/accounts/tree/main) deployment URL. +`DEFINITIONS_WRITE_TOKEN` is the bearer token for `PUT /t/:id` on +[definitions-worker](https://github.com/DIMO-Network/definitions-worker). It is +read only by `src/services/definitions.ts`, which throws if it is ever evaluated +in a browser — the token must never reach a client bundle. +`DIMO_CURATOR_ADDRESSES` lists the addresses allowed to set +`hardwareTemplateId`, which decides what hardware ships and is never open to +contributors. + 3. Install the dependencies: ```bash diff --git a/__tests__/unit/app/templateRoute.test.ts b/__tests__/unit/app/templateRoute.test.ts new file mode 100644 index 00000000..d7d921a4 --- /dev/null +++ b/__tests__/unit/app/templateRoute.test.ts @@ -0,0 +1,441 @@ +/** + * @jest-environment node + */ +import { GET, PUT } from '@/app/api/templates/[id]/route'; +import { NextRequest } from 'next/server'; + +jest.mock('@/services/definitions'); +jest.mock('@/services/templateEntitlement', () => ({ + ...jest.requireActual('@/services/templateEntitlement'), + resolveCaller: jest.fn(), + countMintedVehicles: jest.fn().mockResolvedValue(0), + manufacturerOwner: jest.fn().mockResolvedValue(null), + curatorAddresses: jest.fn().mockReturnValue([]), +})); + +import { fetchTemplate, fetchVocabulary, publishTemplate } from '@/services/definitions'; +import { + resolveCaller, + countMintedVehicles, + curatorAddresses, + manufacturerOwner, +} from '@/services/templateEntitlement'; + +const actual = jest.requireActual('@/services/templateEntitlement'); + +const CALLER = '0x1111111111111111111111111111111111111111'; +const OTHER = '0x2222222222222222222222222222222222222222'; +const params = { params: Promise.resolve({ id: 'toyota_camry_2020' }) }; + +const body = (over: Record = {}) => ({ + id: 'toyota_camry_2020', + deviceType: 'vehicle', + manufacturer: { slug: 'toyota', name: 'Toyota' }, + model: 'Camry', + year: 2020, + attributes: {}, + trims: [{ name: 'LE', attributes: {} }], + ...over, +}); + +const put = (payload: unknown, headers: Record = {}) => + new NextRequest('https://console.test/api/templates/toyota_camry_2020', { + method: 'PUT', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(payload), + }); + +const stored = { ...body(), version: 3, author: CALLER, createdAt: 'x', updatedAt: 'y' }; + +describe('PUT /api/templates/[id]', () => { + beforeEach(() => { + (resolveCaller as jest.Mock).mockResolvedValue({ address: CALLER, email: 'a@b.c' }); + (fetchTemplate as jest.Mock).mockResolvedValue(stored); + (publishTemplate as jest.Mock).mockResolvedValue({ + ok: true, + template: { ...stored, version: 4 }, + }); + (countMintedVehicles as jest.Mock).mockResolvedValue(0); + (manufacturerOwner as jest.Mock).mockResolvedValue({ + kind: 'found', + owner: OTHER, + tokenId: 131, + }); + (curatorAddresses as jest.Mock).mockReturnValue([]); + }); + + it('401s when there is no session', async () => { + (resolveCaller as jest.Mock).mockResolvedValue(null); + expect((await PUT(put(body()), params)).status).toBe(401); + }); + + it('stamps author from the session and never from the body', async () => { + await PUT(put(body(), { 'if-match': '"3"' }), params); + expect((publishTemplate as jest.Mock).mock.calls[0][1].author).toBe(CALLER); + }); + + it('rejects a body that tries to name its own author or version, rather than stripping it quietly', async () => { + for (const field of ['author', 'version', 'createdAt', 'updatedAt']) { + const resp = await PUT( + put(body({ [field]: field === 'version' ? 9 : 'x' })), + params, + ); + expect(resp.status).toBe(400); + expect((await resp.json()).error).toContain(field); + } + }); + + it('requires If-Match when the template already exists', async () => { + const resp = await PUT(put(body()), params); + expect(resp.status).toBe(428); + }); + + it('forwards the client If-Match rather than the version it just read', async () => { + // The freshly-read version would silently rebase a stale editor onto + // whatever landed while it was open. The client's own version is the only + // one that means "this is what I edited". + await PUT(put(body(), { 'if-match': '"2"' }), params); + expect((publishTemplate as jest.Mock).mock.calls[0][2]).toEqual({ + kind: 'update', + version: 2, + }); + }); + + it('sends If-None-Match only for a client that sent no If-Match at all', async () => { + (fetchTemplate as jest.Mock).mockResolvedValue(null); + await PUT(put(body()), params); + expect((publishTemplate as jest.Mock).mock.calls[0][2]).toEqual({ kind: 'create' }); + }); + + it('forwards an explicit create-only precondition over a template that exists', async () => { + // The create page means create, and says so. Answering 428 to it -- "send + // the version you loaded" -- names a version it never had and hides the + // already-exists state it has a dedicated panel for. Forwarded, the worker + // answers 412 and the route turns that into the 409 the page handles. + await PUT(put(body(), { 'if-none-match': '*' }), params); + expect((publishTemplate as jest.Mock).mock.calls[0][2]).toEqual({ kind: 'create' }); + }); + + it('answers 409, not 428, when a create lands on an id that is taken', async () => { + (publishTemplate as jest.Mock).mockResolvedValue({ + ok: false, + kind: 'conflict', + expected: null, + actual: 4, + }); + const resp = await PUT(put(body(), { 'if-none-match': '*' }), params); + expect(resp.status).toBe(409); + expect(await resp.json()).toMatchObject({ conflict: { expected: null, actual: 4 } }); + }); + + it('keeps 428 for a genuine update that sent no precondition at all', async () => { + const resp = await PUT(put(body()), params); + expect(resp.status).toBe(428); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('refuses an If-None-Match that is not "*", rather than guessing at the intent', async () => { + const resp = await PUT(put(body(), { 'if-none-match': '"3"' }), params); + expect(resp.status).toBe(428); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('lets If-Match win when a client sends both', async () => { + // Both is a contradiction. The one that names a version is the one that + // cannot silently overwrite anything. + await PUT(put(body(), { 'if-match': '"3"', 'if-none-match': '*' }), params); + expect((publishTemplate as jest.Mock).mock.calls[0][2]).toEqual({ + kind: 'update', + version: 3, + }); + }); + + it('forwards the client If-Match even when the stored template reads as null', async () => { + // Someone deleted the template under an open editor. Swapping the client's + // If-Match for a create-only precondition would commit the stale draft and + // answer 200 -- resurrecting a deleted template, which is exactly the lost + // update the worker's CAS exists to make impossible. Forwarded, the worker + // answers 412 and the editor learns the template is gone. + (fetchTemplate as jest.Mock).mockResolvedValue(null); + await PUT(put(body(), { 'if-match': '"5"' }), params); + expect((publishTemplate as jest.Mock).mock.calls[0][2]).toEqual({ + kind: 'update', + version: 5, + }); + }); + + it('refuses an If-Match it cannot read as a version, on a create as much as an edit', async () => { + (fetchTemplate as jest.Mock).mockResolvedValue(null); + const resp = await PUT(put(body(), { 'if-match': '"not-a-version"' }), params); + expect(resp.status).toBe(428); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('403s a caller who needs a proposal, and names the count', async () => { + (countMintedVehicles as jest.Mock).mockResolvedValue(4212); + const resp = await PUT(put(body(), { 'if-match': '"3"' }), params); + expect(resp.status).toBe(403); + expect((await resp.json()).entitlement).toMatchObject({ + kind: 'proposal-required', + mintedVehicles: 4212, + }); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('503s, publishing nothing, when the vehicle count cannot be verified', async () => { + // Not a 403: nobody was refused, identity did not answer. A client should + // retry, not re-request access. + (countMintedVehicles as jest.Mock).mockRejectedValueOnce( + new Error('identity-api returned 500'), + ); + const resp = await PUT(put(body(), { 'if-match': '"3"' }), params); + expect(resp.status).toBe(503); + const json = await resp.json(); + expect(json.entitlement).toMatchObject({ kind: 'unavailable', canPublish: false }); + expect(json.error).toMatch(/try again/i); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('403s a hardwareTemplateId change from a non-curator, at every tier', async () => { + const resp = await PUT( + put(body({ hardwareTemplateId: '999' }), { 'if-match': '"3"' }), + params, + ); + expect(resp.status).toBe(403); + expect((await resp.json()).error).toContain('hardwareTemplateId'); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('lets a curator set hardwareTemplateId', async () => { + (curatorAddresses as jest.Mock).mockReturnValue([CALLER.toLowerCase()]); + const resp = await PUT( + put(body({ hardwareTemplateId: '999' }), { 'if-match': '"3"' }), + params, + ); + expect(resp.status).toBe(200); + }); + + describe('per-trim hardwareTemplateId', () => { + // The worker's TRIM_KEYS carry hardwareTemplateId too, so a body whose + // top-level value is untouched can still change what hardware a trim ships. + const trim = (name: string, hardwareTemplateId?: string) => ({ + name, + ...(hardwareTemplateId ? { hardwareTemplateId } : {}), + attributes: {}, + }); + const storedWithTrimHw = { ...stored, trims: [trim('LE', '130')] }; + + it('403s a non-curator who changes a trim hardwareTemplateId under an unchanged top-level one', async () => { + (fetchTemplate as jest.Mock).mockResolvedValue(storedWithTrimHw); + const resp = await PUT( + put(body({ trims: [trim('LE', '999')] }), { 'if-match': '"3"' }), + params, + ); + expect(resp.status).toBe(403); + expect((await resp.json()).error).toContain('hardwareTemplateId'); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('lets a non-curator resubmit a trim hardwareTemplateId unchanged', async () => { + (fetchTemplate as jest.Mock).mockResolvedValue(storedWithTrimHw); + const resp = await PUT( + put(body({ trims: [trim('LE', '130')] }), { 'if-match': '"3"' }), + params, + ); + expect(resp.status).toBe(200); + }); + + it('lets a curator change a trim hardwareTemplateId', async () => { + (curatorAddresses as jest.Mock).mockReturnValue([CALLER.toLowerCase()]); + (fetchTemplate as jest.Mock).mockResolvedValue(storedWithTrimHw); + const resp = await PUT( + put(body({ trims: [trim('LE', '999')] }), { 'if-match': '"3"' }), + params, + ); + expect(resp.status).toBe(200); + }); + + it('403s a non-curator who adds a trim carrying a hardwareTemplateId', async () => { + const resp = await PUT( + put(body({ trims: [trim('LE'), trim('XLE', '999')] }), { 'if-match': '"3"' }), + params, + ); + expect(resp.status).toBe(403); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('lets a non-curator add a trim without one', async () => { + const resp = await PUT( + put(body({ trims: [trim('LE'), trim('XLE')] }), { 'if-match': '"3"' }), + params, + ); + expect(resp.status).toBe(200); + }); + + it('403s a non-curator who drops a trim hardwareTemplateId the stored template has', async () => { + (fetchTemplate as jest.Mock).mockResolvedValue(storedWithTrimHw); + const resp = await PUT( + put(body({ trims: [trim('LE')] }), { 'if-match': '"3"' }), + params, + ); + expect(resp.status).toBe(403); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('403s a non-curator creating a template whose trim carries a hardwareTemplateId', async () => { + // There is no stored template to compare against on a create, so any + // per-trim value from a non-curator is a change. + (fetchTemplate as jest.Mock).mockResolvedValue(null); + const resp = await PUT(put(body({ trims: [trim('LE', '130')] })), params); + expect(resp.status).toBe(403); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + }); + + describe('manufacturer token id', () => { + // The worker requires a positive integer manufacturer.tokenId on every + // template. The create form has no input for it and must not have one -- + // it is the Manufacturer NFT id, which identity owns -- so without this + // stamping every create is a guaranteed 422 on a field nobody can fill in. + it('stamps the token id identity reports, beside the author', async () => { + (fetchTemplate as jest.Mock).mockResolvedValue(null); + await PUT(put(body()), params); + const payload = (publishTemplate as jest.Mock).mock.calls[0][1]; + expect(payload.manufacturer).toEqual({ + slug: 'toyota', + name: 'Toyota', + tokenId: 131, + }); + expect(manufacturerOwner).toHaveBeenCalledWith('toyota'); + }); + + it('overrules a token id the body carries rather than trusting it', async () => { + await PUT( + put(body({ manufacturer: { slug: 'toyota', name: 'Toyota', tokenId: 9999 } }), { + 'if-match': '"3"', + }), + params, + ); + expect((publishTemplate as jest.Mock).mock.calls[0][1].manufacturer.tokenId).toBe( + 131, + ); + }); + + it('names the unresolvable manufacturer instead of letting the worker 422 a field the form has no input for', async () => { + // Driven through the real lookup over identity's own wire shape, not a + // mock resolving null. `manufacturer(by:)` is nullable in identity's + // schema, so an unregistered make is HTTP 200 with the key present and + // null and no `errors` entry -- and this 422 is only reachable if the + // Console reads exactly that as "there is no such manufacturer". + (fetchTemplate as jest.Mock).mockResolvedValue(null); + (manufacturerOwner as jest.Mock).mockImplementation(actual.manufacturerOwner); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: { manufacturer: null } }), + }) as unknown as typeof fetch; + + const resp = await PUT(put(body()), params); + expect(resp.status).toBe(422); + expect((await resp.json()).errors[0]).toContain('toyota'); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('503s, not 422, when identity answers the same lookup with an error', async () => { + // The other half of identity's contract. A real failure carries an errors + // entry reading exactly "Internal error" -- identity ca4c4c3 logs the + // driver string and never serves it -- so the two cases are told apart by + // whether there is an errors entry, never by its text. An outage must + // stay a retry, never a denial naming the curator's make. + (fetchTemplate as jest.Mock).mockResolvedValue(null); + (manufacturerOwner as jest.Mock).mockImplementation(actual.manufacturerOwner); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: null, + errors: [{ message: 'Internal error' }], + }), + }) as unknown as typeof fetch; + + const resp = await PUT(put(body()), params); + expect(resp.status).toBe(503); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + + it('503s rather than deciding when identity cannot answer', async () => { + (manufacturerOwner as jest.Mock).mockRejectedValue( + new Error('identity-api returned 500'), + ); + const resp = await PUT(put(body(), { 'if-match': '"3"' }), params); + expect(resp.status).toBe(503); + expect((await resp.json()).error).toMatch(/try again/i); + expect(publishTemplate).not.toHaveBeenCalled(); + }); + }); + + it('passes the worker validation errors through unchanged', async () => { + (publishTemplate as jest.Mock).mockResolvedValue({ + ok: false, + kind: 'validation', + errors: ['template: unknown attribute "nope"'], + }); + const resp = await PUT(put(body(), { 'if-match': '"3"' }), params); + expect(resp.status).toBe(422); + expect((await resp.json()).errors).toEqual(['template: unknown attribute "nope"']); + }); + + it('turns a worker 412 into a 409 carrying the version to rebase onto', async () => { + (publishTemplate as jest.Mock).mockResolvedValue({ + ok: false, + kind: 'conflict', + expected: 3, + actual: 5, + }); + const resp = await PUT(put(body(), { 'if-match': '"3"' }), params); + expect(resp.status).toBe(409); + expect(await resp.json()).toMatchObject({ conflict: { expected: 3, actual: 5 } }); + }); +}); + +describe('GET /api/templates/[id]', () => { + it('returns the template, the live vocabulary and the caller entitlement in one payload', async () => { + (resolveCaller as jest.Mock).mockResolvedValue({ address: CALLER, email: 'a@b.c' }); + (fetchTemplate as jest.Mock).mockResolvedValue(stored); + (fetchVocabulary as jest.Mock).mockResolvedValue({ + id: 'vehicle', + name: 'Vehicle', + attributes: [], + }); + const json = await ( + await GET( + new NextRequest('https://console.test/api/templates/toyota_camry_2020'), + params, + ) + ).json(); + expect(json.template.version).toBe(3); + expect(json.vocabulary.id).toBe('vehicle'); + expect(json.entitlement.kind).toBe('author'); + }); + + it('still loads the template, read only, when the vehicle count cannot be verified', async () => { + (resolveCaller as jest.Mock).mockResolvedValue({ address: CALLER, email: 'a@b.c' }); + (fetchTemplate as jest.Mock).mockResolvedValue(stored); + (fetchVocabulary as jest.Mock).mockResolvedValue({ + id: 'vehicle', + name: 'Vehicle', + attributes: [], + }); + (countMintedVehicles as jest.Mock).mockRejectedValueOnce( + new Error('identity-api returned 500'), + ); + const resp = await GET( + new NextRequest('https://console.test/api/templates/toyota_camry_2020'), + params, + ); + expect(resp.status).toBe(200); + const json = await resp.json(); + expect(json.template.version).toBe(3); + expect(json.entitlement).toMatchObject({ kind: 'unavailable', canPublish: false }); + }); +}); diff --git a/__tests__/unit/app/templatesSearchRoute.test.ts b/__tests__/unit/app/templatesSearchRoute.test.ts new file mode 100644 index 00000000..38e2fafb --- /dev/null +++ b/__tests__/unit/app/templatesSearchRoute.test.ts @@ -0,0 +1,103 @@ +/** + * @jest-environment node + * + * Route handlers run on the server and this one reaches @/services/definitions, + * which refuses to load where `window` exists. + */ +import { GET } from '@/app/api/templates/route'; +import { NextRequest } from 'next/server'; + +jest.mock('@/services/definitions', () => ({ + fetchTemplate: jest.fn(), +})); +import { fetchTemplate } from '@/services/definitions'; + +const identityResponse = { + data: { + manufacturer: { + name: 'Toyota', + tokenId: 131, + deviceDefinitions: { + nodes: [ + { deviceDefinitionId: 'toyota_camry_2020', model: 'Camry', year: 2020 }, + { deviceDefinitionId: 'toyota_supra_2020', model: 'Supra', year: 2020 }, + ], + }, + }, + }, +}; + +const req = (qs: string) => new NextRequest(`https://console.test/api/templates?${qs}`); + +describe('GET /api/templates', () => { + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => identityResponse, + }) as unknown as typeof fetch; + }); + + it('requires a make', async () => { + expect((await GET(req('model=Camry'))).status).toBe(400); + }); + + it('marks a definition with no template as missing rather than failing', async () => { + (fetchTemplate as jest.Mock) + .mockResolvedValueOnce({ id: 'toyota_camry_2020', version: 3, trims: [{}, {}] }) + .mockResolvedValueOnce(null); + const body = await (await GET(req('make=Toyota&model=Camry&year=2020'))).json(); + expect(body.results).toEqual([ + { + id: 'toyota_camry_2020', + model: 'Camry', + year: 2020, + status: 'ok', + version: 3, + trims: 2, + }, + { id: 'toyota_supra_2020', model: 'Supra', year: 2020, status: 'missing' }, + ]); + // This listing is the one place a cached template is fine, and it has to + // say so: fetchTemplate misses the edge by default because every other + // caller's read feeds a write. + expect(fetchTemplate).toHaveBeenCalledWith('toyota_camry_2020', { + allowEdgeCache: true, + }); + }); + + it('marks an id the schema cannot accept, without asking the worker about it', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + manufacturer: { + name: 'Subaru', + tokenId: 1, + deviceDefinitions: { + nodes: [ + { + deviceDefinitionId: 'subaru_tribeca-(ny/nj)_2008', + model: 'Tribeca', + year: 2008, + }, + ], + }, + }, + }, + }), + }) as unknown as typeof fetch; + (fetchTemplate as jest.Mock).mockClear(); + const body = await (await GET(req('make=Subaru'))).json(); + expect(body.results[0].status).toBe('invalid-id'); + expect(fetchTemplate).not.toHaveBeenCalled(); + }); + + it('reports an unknown manufacturer as null rather than an empty result set', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: { manufacturer: null } }), + }) as unknown as typeof fetch; + const body = await (await GET(req('make=Nope'))).json(); + expect(body).toEqual({ manufacturer: null, results: [] }); + }); +}); diff --git a/__tests__/unit/services/definitions.test.ts b/__tests__/unit/services/definitions.test.ts new file mode 100644 index 00000000..bd965f5c --- /dev/null +++ b/__tests__/unit/services/definitions.test.ts @@ -0,0 +1,155 @@ +/** + * @jest-environment node + * + * services/definitions is server-only and throws when `window` exists, which is + * the guarantee that DEFINITIONS_WRITE_TOKEN cannot reach a browser bundle. The + * default jsdom environment therefore cannot load it at all -- see the + * "refuses to load in a browser" case below, which asserts exactly that. + */ +import { fetchTemplate, fetchVocabulary, publishTemplate } from '@/services/definitions'; +import type { TemplatePayload } from '@/types/template'; + +const payload = { + id: 'toyota_camry_2020', + deviceType: 'vehicle', + manufacturer: { slug: 'toyota', name: 'Toyota' }, + model: 'Camry', + year: 2020, + attributes: {}, + trims: [{ name: 'LE', attributes: {} }], +} as unknown as TemplatePayload; + +const mockFetch = (impl: jest.Mock) => { + global.fetch = impl as unknown as typeof fetch; + return impl; +}; + +describe('definitions service', () => { + it('returns null for a template that does not exist yet', async () => { + mockFetch( + jest.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }), + ); + expect(await fetchTemplate('ineos_grenadier_2024')).toBeNull(); + }); + + it('misses the edge cache on every read whose answer feeds a write', async () => { + // config.definitionsWorkerUrl is definitions.dimo.org and the worker serves + // /t/ `public, max-age=86400, stale-while-revalidate=604800`, so a + // plain GET can be answered by Cloudflare with a day-old document. The + // editor reads, mutates and PUTs back with the version it read, so a + // cached read sends a stale If-Match and conflicts with itself. `cache: + // 'no-store'` does not help: it bypasses Next's Data Cache only, never the + // edge -- dd-api proved the same against this exact host and settled on a + // unique query parameter the worker ignores and the edge cannot match. + const f = mockFetch( + jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ...payload, version: 1 }), + }), + ); + + await fetchTemplate('toyota_camry_2020'); + await fetchTemplate('toyota_camry_2020'); + + const first = new URL(f.mock.calls[0][0]); + const second = new URL(f.mock.calls[1][0]); + expect(first.pathname).toBe('/t/toyota_camry_2020'); + expect(first.searchParams.get('fresh')).toBeTruthy(); + // Two reads inside one clock tick still have to be two cache keys. + expect(second.searchParams.get('fresh')).not.toBe(first.searchParams.get('fresh')); + }); + + it('lets a browse listing keep the edge cache, explicitly', async () => { + // The search page reads up to 25 templates per keystroke-ish query and does + // nothing with them but render a version and a trim count. Nothing it reads + // is ever written back, so paying for 25 origin hits buys nothing. + const f = mockFetch( + jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ...payload, version: 1 }), + }), + ); + await fetchTemplate('toyota_camry_2020', { allowEdgeCache: true }); + expect(new URL(f.mock.calls[0][0]).search).toBe(''); + }); + + it('sends If-None-Match on create and If-Match on update, and never the token to the body', async () => { + process.env.DEFINITIONS_WRITE_TOKEN = 'secret-token'; + const f = mockFetch( + jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ...payload, version: 1 }), + }), + ); + + await publishTemplate('toyota_camry_2020', payload, { kind: 'create' }); + expect(f.mock.calls[0][1].headers['If-None-Match']).toBe('*'); + expect(f.mock.calls[0][1].headers.Authorization).toBe('Bearer secret-token'); + expect(f.mock.calls[0][1].body).not.toContain('secret-token'); + + await publishTemplate('toyota_camry_2020', payload, { kind: 'update', version: 7 }); + expect(f.mock.calls[1][1].headers['If-Match']).toBe('"7"'); + }); + + it('maps 422 to validation errors and 412 to a conflict', async () => { + mockFetch( + jest.fn().mockResolvedValue({ + ok: false, + status: 422, + json: async () => ({ errors: ['template: unknown attribute "nope"'] }), + }), + ); + expect( + await publishTemplate('toyota_camry_2020', payload, { kind: 'create' }), + ).toEqual({ + ok: false, + kind: 'validation', + errors: ['template: unknown attribute "nope"'], + }); + + mockFetch( + jest.fn().mockResolvedValue({ + ok: false, + status: 412, + json: async () => ({ + error: 'expected version 6 but the current version is 7', + expected: 6, + actual: 7, + }), + }), + ); + expect( + await publishTemplate('toyota_camry_2020', payload, { kind: 'update', version: 6 }), + ).toEqual({ ok: false, kind: 'conflict', expected: 6, actual: 7 }); + }); + + it('refuses a payload over the worker 64KB cap before spending a request', async () => { + const f = mockFetch(jest.fn()); + const fat = { + ...payload, + trims: Array.from({ length: 4000 }, (_, i) => ({ name: `T${i}`, attributes: {} })), + }; + const result = await publishTemplate( + 'toyota_camry_2020', + fat as unknown as TemplatePayload, + { kind: 'create' }, + ); + expect(result).toMatchObject({ ok: false, kind: 'too-large' }); + expect(f).not.toHaveBeenCalled(); + }); + + it('fetches the vocabulary from the worker, not from a vendored copy', async () => { + const f = mockFetch( + jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: 'vehicle', name: 'Vehicle', attributes: [] }), + }), + ); + await fetchVocabulary(); + expect(f.mock.calls[0][0]).toContain('/schema/device-type-vehicle.json'); + }); +}); diff --git a/__tests__/unit/services/definitionsServerOnly.test.ts b/__tests__/unit/services/definitionsServerOnly.test.ts new file mode 100644 index 00000000..bd9e6ea3 --- /dev/null +++ b/__tests__/unit/services/definitionsServerOnly.test.ts @@ -0,0 +1,11 @@ +/** + * Runs in jsdom (the project default) on purpose: importing the server-only + * module where `window` exists must fail. This is gate 1's guarantee, and a + * convention would not be testable. + */ +describe('services/definitions in a browser', () => { + it('refuses to load', async () => { + const { fetchTemplate } = await import('@/services/definitions'); + await expect(fetchTemplate('toyota_camry_2020')).rejects.toThrow(/server-only/); + }); +}); diff --git a/__tests__/unit/services/templateEntitlement.test.ts b/__tests__/unit/services/templateEntitlement.test.ts new file mode 100644 index 00000000..05f54923 --- /dev/null +++ b/__tests__/unit/services/templateEntitlement.test.ts @@ -0,0 +1,475 @@ +/** + * @jest-environment node + */ +import { + IdentityError, + countMintedVehicles, + hardwareTemplateIdChanged, + manufacturerOwner, + resolveEntitlement, +} from '@/services/templateEntitlement'; +import type { Template } from '@/types/template'; + +const CALLER = '0x1111111111111111111111111111111111111111'; +const OTHER = '0x2222222222222222222222222222222222222222'; +const CURATOR = '0x3333333333333333333333333333333333333333'; + +const template = (over: Partial