Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,22 @@ RPC_URL=<YOUR_RPC>
NEXT_PUBLIC_TURNKEY_API_BASE_URL="https://api.turnkey.com"
NEXT_PUBLIC_RPID="localhost"
NEXT_PUBLIC_GA_API=<YOUR_ACCOUNTS_API_URL>

# Vehicle templates. Both are server-only -- no NEXT_PUBLIC_ prefix, ever.
DEFINITIONS_WRITE_TOKEN=<WRITE_TOKEN_FOR_DEFINITIONS_WORKER>
DIMO_CURATOR_ADDRESSES=<COMMA_SEPARATED_0X_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
Expand Down
174 changes: 174 additions & 0 deletions __tests__/unit/app/templateRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => ({
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<string, string> = {}) =>
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');
});
});
97 changes: 97 additions & 0 deletions __tests__/unit/app/templatesSearchRoute.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] });
});
});
Loading