feat: rebuild warm operations workbench
This commit is contained in:
@@ -104,8 +104,13 @@ describe('safe operation client', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses uncatalogued operations and mismatched schemas without a request', async () => {
|
||||
const fetcher = vi.fn(async () => response(page));
|
||||
it('refuses uncatalogued operations and mismatched schemas without a prepare request', async () => {
|
||||
const fetcher = vi.fn(async (url: string) => {
|
||||
if (String(url).includes('search=hiddenOp')) {
|
||||
return response({ items: [], page: { page: 1, pageSize: 100, total: 0 } });
|
||||
}
|
||||
return response(page);
|
||||
});
|
||||
const client = createOperationClient(fetcher as typeof fetch);
|
||||
await client.list();
|
||||
|
||||
@@ -123,7 +128,52 @@ describe('safe operation client', () => {
|
||||
parameters: { parameterSchemaId: 'wrong', fields: [] },
|
||||
}),
|
||||
).rejects.toThrow('Operation parameter schema does not match the catalog.');
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
// list + one search hydrate for the missing op; no prepare POST.
|
||||
expect(fetcher.mock.calls.every((call) => !String(call[0]).includes('/prepare'))).toBe(true);
|
||||
});
|
||||
|
||||
it('hydrates late-sorted restart ops when the first pageSize=100 page omits them', async () => {
|
||||
const restartEntry = {
|
||||
operationId: 'postServiceRestart',
|
||||
title: 'Restart Service',
|
||||
risk: 'R3',
|
||||
capability: 'job',
|
||||
batchable: false,
|
||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||||
};
|
||||
const firstPage = {
|
||||
items: [entry],
|
||||
page: { page: 1, pageSize: 100, total: 117 },
|
||||
};
|
||||
const searchPage = {
|
||||
items: [restartEntry],
|
||||
page: { page: 1, pageSize: 100, total: 1 },
|
||||
};
|
||||
const restartPreparation = {
|
||||
...preparation,
|
||||
operationId: 'postServiceRestart',
|
||||
risk: 'R3',
|
||||
confirmationPrompt: 'Confirm restart service',
|
||||
};
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response(firstPage))
|
||||
.mockResolvedValueOnce(response(searchPage))
|
||||
.mockResolvedValueOnce(response(restartPreparation));
|
||||
const client = createOperationClient(fetcher as typeof fetch);
|
||||
await client.list({ pageSize: 100 });
|
||||
|
||||
const result = await client.prepare({
|
||||
operationId: 'postServiceRestart',
|
||||
targets: [{ instanceId: 'instance-1', revision: 1 }],
|
||||
parameters: {
|
||||
parameterSchemaId: 'simadmin.58e2204.postServiceRestart.parameters.v1',
|
||||
fields: [],
|
||||
},
|
||||
});
|
||||
expect(result.operationId).toBe('postServiceRestart');
|
||||
expect(String(fetcher.mock.calls[1]?.[0])).toContain('search=postServiceRestart');
|
||||
expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/operations/prepare');
|
||||
});
|
||||
|
||||
it('executes with the in-memory token exactly once and validates public Job fields', async () => {
|
||||
|
||||
@@ -369,25 +369,39 @@ function queryString(query: OperationCatalogQuery): string {
|
||||
export function createOperationClient(fetcher: typeof fetch = fetch): OperationClient {
|
||||
const catalog = new Map<string, OperationCatalogEntry>();
|
||||
const confirmations = new Map<string, Readonly<{ token: string; operationId: string }>>();
|
||||
|
||||
async function list(query: OperationCatalogQuery = {}): Promise<OperationCatalogPage> {
|
||||
const body = await jsonResponse(
|
||||
await fetcher(`/api/v1/operations${queryString(query)}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
}),
|
||||
);
|
||||
const parsed = parseCatalog(body);
|
||||
if (!parsed) throw new Error('Operation catalog response is invalid.');
|
||||
// Merge rather than replace: a pageSize-capped first page must not wipe later lookups
|
||||
// for late-sorted ids such as postServiceRestart / postSystemReboot.
|
||||
for (const item of parsed.items) catalog.set(item.operationId, item);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function ensureCatalogEntry(
|
||||
operationId: string,
|
||||
): Promise<OperationCatalogEntry | undefined> {
|
||||
const existing = catalog.get(operationId);
|
||||
if (existing) return existing;
|
||||
// Catalog is sorted by operationId and capped at pageSize 100; hydrate by exact search.
|
||||
await list({ search: operationId, pageSize: 100 });
|
||||
return catalog.get(operationId);
|
||||
}
|
||||
|
||||
return {
|
||||
async list(query = {}) {
|
||||
const body = await jsonResponse(
|
||||
await fetcher(`/api/v1/operations${queryString(query)}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { accept: 'application/json' },
|
||||
}),
|
||||
);
|
||||
const parsed = parseCatalog(body);
|
||||
if (!parsed) throw new Error('Operation catalog response is invalid.');
|
||||
catalog.clear();
|
||||
for (const item of parsed.items) catalog.set(item.operationId, item);
|
||||
return parsed;
|
||||
},
|
||||
list,
|
||||
async prepare(input) {
|
||||
const safeInput = safePrepareInput(input);
|
||||
if (!safeInput) throw new Error('Operation preparation request is invalid.');
|
||||
const allowed = catalog.get(safeInput.operationId);
|
||||
const allowed = await ensureCatalogEntry(safeInput.operationId);
|
||||
if (!allowed) throw new Error('Operation is not present in the loaded catalog.');
|
||||
if (allowed.parameterSchemaId !== safeInput.parameters.parameterSchemaId)
|
||||
throw new Error('Operation parameter schema does not match the catalog.');
|
||||
|
||||
Reference in New Issue
Block a user