feat(web): rebuild the console around the fused control plane
Render the Hub feature set in the existing Animal Island styling: fleet messages, notification centre, log centre, organisation panel, per-instance device module panels and the settings backup, connection and maintenance surfaces.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createComponentBackupApiDataSource } from './component-backup-api-data-source.js';
|
||||
|
||||
const catalog = {
|
||||
items: [
|
||||
{
|
||||
key: 'devices',
|
||||
label: '设备与分组',
|
||||
description: '节点地址、分组、标签与能力快照。',
|
||||
rows: 7,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const backup = {
|
||||
filename: 'multi-simadmin-components-2026-09-04T03-30-00-000Z.json',
|
||||
createdAt: '2026-09-04T03:30:00.000Z',
|
||||
sizeBytes: 4096,
|
||||
appVersion: '0.1.0',
|
||||
note: '升级前',
|
||||
automatic: true,
|
||||
integrity: 'ok',
|
||||
compatible: true,
|
||||
components: [{ key: 'devices', label: '设备与分组', description: '', rows: 7 }],
|
||||
};
|
||||
|
||||
const settings = {
|
||||
enabled: true,
|
||||
components: ['devices'],
|
||||
timeOfDay: '03:30',
|
||||
weekday: -1,
|
||||
maximumCount: 14,
|
||||
lastRunAt: null,
|
||||
};
|
||||
|
||||
const json = (body: unknown, status = 200): Response =>
|
||||
new Response(JSON.stringify(body), { status });
|
||||
|
||||
describe('component backup API data source', () => {
|
||||
it('reads the catalog, list and schedule with same-origin controls', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(json(catalog))
|
||||
.mockResolvedValueOnce(json({ items: [backup] }))
|
||||
.mockResolvedValueOnce(json(settings));
|
||||
const source = createComponentBackupApiDataSource(fetcher);
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await expect(source.catalog(signal)).resolves.toEqual(catalog.items);
|
||||
await expect(source.list(signal)).resolves.toEqual([backup]);
|
||||
await expect(source.autoSettings(signal)).resolves.toEqual(settings);
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/v1/system/component-backups/catalog');
|
||||
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/v1/system/component-backups');
|
||||
expect(fetcher.mock.calls[2]?.[0]).toBe('/api/v1/system/component-backups/auto/settings');
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ credentials: 'same-origin', signal });
|
||||
});
|
||||
|
||||
it('posts create, restore and delete against the encoded filename', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(json(backup, 201))
|
||||
.mockResolvedValueOnce(json({ devices: 7 }))
|
||||
.mockResolvedValueOnce(json({ filename: backup.filename }));
|
||||
const source = createComponentBackupApiDataSource(fetcher);
|
||||
|
||||
await expect(source.create(['devices'], '升级前')).resolves.toEqual(backup);
|
||||
await expect(source.restore(backup.filename, ['devices'])).resolves.toBeUndefined();
|
||||
await expect(source.remove(backup.filename)).resolves.toBeUndefined();
|
||||
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ components: ['devices'], note: '升级前' }),
|
||||
});
|
||||
expect(fetcher.mock.calls[1]?.[0]).toBe(
|
||||
`/api/v1/system/component-backups/${encodeURIComponent(backup.filename)}/restore`,
|
||||
);
|
||||
expect(fetcher.mock.calls[2]?.[1]).toMatchObject({ method: 'DELETE' });
|
||||
});
|
||||
|
||||
it('saves the schedule and rejects an unknown component key', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(settings));
|
||||
const source = createComponentBackupApiDataSource(fetcher);
|
||||
await expect(
|
||||
source.saveAutoSettings({
|
||||
enabled: true,
|
||||
components: ['devices'],
|
||||
timeOfDay: '03:30',
|
||||
weekday: -1,
|
||||
maximumCount: 14,
|
||||
}),
|
||||
).resolves.toEqual(settings);
|
||||
await expect(source.create(['secretReferences' as never], '')).rejects.toThrowError(
|
||||
'Component backup response is invalid.',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a payload whose integrity flag is not understood', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(json({ items: [{ ...backup, integrity: 'maybe' }] }));
|
||||
await expect(createComponentBackupApiDataSource(fetcher).list()).rejects.toThrowError(
|
||||
'Component backup response is invalid.',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a filename that could escape the backup directory', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json(backup));
|
||||
await expect(
|
||||
createComponentBackupApiDataSource(fetcher).remove('../secret.json'),
|
||||
).rejects.toThrowError('Component backup response is invalid.');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user