- 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.
149 lines
5.7 KiB
TypeScript
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.');
|
|
});
|
|
});
|