// @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() .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() .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() .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() .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() .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); }); });