feat(web): complete fleet and instance management slices
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user