136 lines
5.4 KiB
TypeScript
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);
|
|
});
|
|
});
|