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..3f5e03b5 --- /dev/null +++ b/__tests__/unit/app/templateRoute.test.ts @@ -0,0 +1,174 @@ +/** + * @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, +} from '@/services/templateEntitlement'; + +const CALLER = '0x1111111111111111111111111111111111111111'; +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); + (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 on a create', 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('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('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); + }); + + 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'); + }); +}); diff --git a/__tests__/unit/app/templatesSearchRoute.test.ts b/__tests__/unit/app/templatesSearchRoute.test.ts new file mode 100644 index 00000000..34c609f4 --- /dev/null +++ b/__tests__/unit/app/templatesSearchRoute.test.ts @@ -0,0 +1,97 @@ +/** + * @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' }, + ]); + }); + + 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..6a920dc2 --- /dev/null +++ b/__tests__/unit/services/definitions.test.ts @@ -0,0 +1,112 @@ +/** + * @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('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..d2922932 --- /dev/null +++ b/__tests__/unit/services/templateEntitlement.test.ts @@ -0,0 +1,119 @@ +/** + * @jest-environment node + */ +import { resolveEntitlement } from '@/services/templateEntitlement'; +import type { Template } from '@/types/template'; + +const CALLER = '0x1111111111111111111111111111111111111111'; +const OTHER = '0x2222222222222222222222222222222222222222'; +const CURATOR = '0x3333333333333333333333333333333333333333'; + +const template = (over: Partial