Files
multi-simadmin/apps/web/src/settings/component-backup-api-data-source.test.ts
T
chick f6319374cd feat(web): add console update panel, system maintenance path and connection settings UI
- Add online update workflow with plan, backup and install states.

- Expose maintenance paths, component backup jobs and connection authorization modes.

- Align auth and automation screens with the fused control plane.
2026-09-07 00:45:44 +08:00

149 lines
5.7 KiB
TypeScript

// @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,
},
],
directory: '/Users/chick/.local/opt/multi-simadmin/data/backups',
};
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',
formatVersion: 1,
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);
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({ written: { devices: 7 }, safetyBackup: 'multi-simadmin-components-auto-x.json' }),
)
.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.toBe(
'multi-simadmin-components-auto-x.json',
);
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('previews an archive and builds a safe download URL', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(json(backup));
const source = createComponentBackupApiDataSource(fetcher);
await expect(source.preview(backup.filename)).resolves.toEqual(backup);
expect(source.backupDownloadUrl(backup.filename)).toBe(
`/api/v1/system/component-backups/${encodeURIComponent(backup.filename)}/download`,
);
expect(fetcher.mock.calls[0]?.[0]).toBe(
`/api/v1/system/component-backups/${encodeURIComponent(backup.filename)}/preview`,
);
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'GET' });
});
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.');
});
it('refuses a catalog that does not say where the backups live', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ items: catalog.items }));
await expect(createComponentBackupApiDataSource(fetcher).catalog()).rejects.toThrowError(
'Component backup response is invalid.',
);
});
it('refuses a restore that cannot prove it took a safety snapshot first', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(json({ written: { devices: 7 } }));
await expect(
createComponentBackupApiDataSource(fetcher).restore(backup.filename, ['devices']),
).rejects.toThrowError('Component backup response is invalid.');
});
});