Files
multi-simadmin/apps/web/src/instances/instance-api-data-source.test.ts
T

136 lines
5.4 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createInstanceApiDataSource,
passwordUpdate,
tagsFromInput,
} from './instance-api-data-source.js';
afterEach(() => vi.unstubAllGlobals());
function response(body: unknown, status = 200, etag?: string): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'content-type': 'application/json',
...(etag === undefined ? {} : { ETag: etag }),
},
});
}
const instance = {
id: 'owner/id',
name: 'Owner',
origin: 'https://owner.example',
tags: ['lab'],
revision: 3,
credentialConfigured: true,
};
describe('instance API data source', () => {
it('carries the exact strong ETag through the owner-bound update lifecycle', async () => {
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(response(instance, 200, '"opaque-owner-v3"'))
.mockResolvedValueOnce(response({ ...instance, revision: 4 }, 200, '"opaque-owner-v4"'))
.mockResolvedValueOnce(response({ ...instance, revision: 5 }, 200, '"opaque-owner-v5"'))
.mockResolvedValueOnce(response({ reachable: true, authenticated: false }));
vi.stubGlobal('fetch', fetch);
const source = createInstanceApiDataSource();
await source.get('owner/id');
await source.update('owner/id', 3, { password: { action: 'preserve' } });
await source.update('owner/id', 4, { name: 'Owner 2' });
await source.testConnection('owner/id');
expect(fetch.mock.calls[0]?.[0]).toBe('/api/v1/instances/owner%2Fid');
expect(fetch.mock.calls[1]?.[1]).toMatchObject({
method: 'PATCH',
headers: expect.objectContaining({ 'If-Match': '"opaque-owner-v3"' }),
body: JSON.stringify({ password: { action: 'preserve' } }),
});
expect(fetch.mock.calls[2]?.[1]).toMatchObject({
method: 'PATCH',
headers: expect.objectContaining({ 'If-Match': '"opaque-owner-v4"' }),
});
expect(fetch.mock.calls[3]?.[0]).toBe('/api/v1/instances/owner%2Fid/test-connection');
});
it('requires a valid strong ETag before allowing an update', async () => {
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(response(instance))
.mockResolvedValueOnce(response(instance, 200, 'W/"rev-3"'));
vi.stubGlobal('fetch', fetch);
const source = createInstanceApiDataSource();
await expect(source.get('owner/id')).rejects.toThrow(/strong etag/i);
await expect(source.get('owner/id')).rejects.toThrow(/strong etag/i);
await expect(source.update('owner/id', 3, { name: 'Nope' })).rejects.toThrow(/etag/i);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('captures the create response ETag for the first update', async () => {
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(response(instance, 201, '"created-owner-v3"'))
.mockResolvedValueOnce(response({ ...instance, revision: 4 }, 200, '"created-owner-v4"'));
vi.stubGlobal('fetch', fetch);
const source = createInstanceApiDataSource();
const created = await source.create({ name: instance.name, origin: instance.origin });
await source.update(created.id, created.revision, { tags: ['new'] });
expect(fetch.mock.calls[1]?.[1]?.headers).toEqual(
expect.objectContaining({ 'If-Match': '"created-owner-v3"' }),
);
});
it('prepares and executes R3 deletion through the durable operation endpoint', async () => {
const token = 'a'.repeat(32);
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(response({ id: 'prep-1', confirmationToken: token }))
.mockResolvedValueOnce(response({ id: 'job-1' }, 202));
vi.stubGlobal('fetch', fetch);
await createInstanceApiDataSource().delete('owner', 3);
expect(JSON.parse(fetch.mock.calls[0]?.[1]?.body as string)).toEqual({
operationId: 'deleteInstance',
targets: [{ instanceId: 'owner', revision: 3 }],
parameters: { parameterSchemaId: 'deleteInstance.parameters.v1', fields: [] },
});
expect(fetch.mock.calls[1]?.[0]).toBe('/api/v1/operations/execute');
expect(fetch.mock.calls[1]?.[1]).toMatchObject({
method: 'POST',
body: JSON.stringify({ preparationId: 'prep-1', confirmationToken: token }),
});
expect(
fetch.mock.calls.some(
([url, init]) => init?.method === 'DELETE' || String(url).includes(token),
),
).toBe(false);
});
it('rejects mismatched owners and does not render arbitrary response text as an error', async () => {
const fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(response({ ...instance, id: 'intruder' }, 200, '"owner-v3"'))
.mockResolvedValueOnce(new Response('password=leaked-secret', { status: 500 }));
vi.stubGlobal('fetch', fetch);
const source = createInstanceApiDataSource();
await expect(source.get('owner')).rejects.toThrow(/does not match this route/i);
await expect(source.get('owner')).rejects.toThrow(
'The instance operation could not be completed.',
);
});
it('normalizes tags and enforces the password discriminated union', () => {
expect(tagsFromInput(' lab, west,lab, ')).toEqual(['lab', 'west']);
expect(passwordUpdate('preserve', '')).toEqual({ action: 'preserve' });
expect(passwordUpdate('clear', '')).toEqual({ action: 'clear' });
expect(() => passwordUpdate('set', '')).toThrow(/enter a password/i);
});
});