mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 06:07:58 +00:00
fix(fleet): isolate corrupt snapshot file decrypt failures (#1650)
* fix(fleet): isolate corrupt snapshot file decrypt failures A single damaged encrypted fleet-snapshot row no longer fails detail, restore, or off-site upload for the whole snapshot. Unavailable members are marked, restore is blocked before mutation, and cloud upload fails closed with no PutObject. * fix(fleet): fail closed on damaged enc snapshot envelopes Unrecognized enc: payloads no longer fall through as usable plaintext. Only clear legacy prose stays readable; delimiter-byte and similar envelope damage stays unavailable through restore and cloud upload. * fix(fleet): subordinate legacy enc prose to envelope shape Legacy exceptions no longer trigger from = or whitespace alone. Encryption-shaped payloads (length and hex density) stay unavailable through restore and cloud upload, while short genuine prose such as enc:hello remains usable. * fix(fleet): preserve non-envelope enc legacy plaintext Any non-empty enc: payload that is not encryption-shaped is kept verbatim, including punctuation forms such as enc:hello-world, while envelope-shaped damage remains unavailable.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import FleetSnapshots from '../FleetSnapshots';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ user: { role: 'admin' }, isAdmin: true }),
|
||||
}));
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => ({ isPaid: true }),
|
||||
}));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockedFetch = vi.mocked(apiFetch);
|
||||
|
||||
describe('FleetSnapshots unavailable files', () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedFetch.mockImplementation(async (path: string) => {
|
||||
if (path === '/fleet/snapshots') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
snapshots: [{
|
||||
id: 7,
|
||||
description: 'fleet-snap-test',
|
||||
created_by: 'admin',
|
||||
node_count: 1,
|
||||
stack_count: 2,
|
||||
skipped_nodes: '[]',
|
||||
skipped_stacks: '[]',
|
||||
created_at: Date.now(),
|
||||
has_documentation: 0,
|
||||
}],
|
||||
total: 1,
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
if (path === '/fleet/snapshots/7') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 7,
|
||||
description: 'fleet-snap-test',
|
||||
created_by: 'admin',
|
||||
node_count: 1,
|
||||
stack_count: 2,
|
||||
skipped_nodes: '[]',
|
||||
skipped_stacks: '[]',
|
||||
created_at: Date.now(),
|
||||
fileDecryptWarnings: [
|
||||
{ nodeId: 1, nodeName: 'local', stackName: 'bad', filename: 'compose.yaml' },
|
||||
],
|
||||
nodes: [{
|
||||
nodeId: 1,
|
||||
nodeName: 'local',
|
||||
stacks: [
|
||||
{
|
||||
stackName: 'good',
|
||||
files: [{ filename: '.env', content: '' }],
|
||||
},
|
||||
{
|
||||
stackName: 'bad',
|
||||
files: [{ filename: 'compose.yaml', unavailable: true }],
|
||||
},
|
||||
],
|
||||
}],
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
if (path === '/cloud-backup/config') {
|
||||
return { ok: true, json: async () => ({ provider: 'disabled' }) } as Response;
|
||||
}
|
||||
if (path === '/cloud-backup/snapshots') {
|
||||
return { ok: true, json: async () => [] } as Response;
|
||||
}
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a decrypt warning and disables restore only for unavailable stacks', async () => {
|
||||
render(<FleetSnapshots />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('fleet-snap-test')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /View/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Some snapshot files could not be decrypted/i)).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText('compose.yaml')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /local/i }));
|
||||
await waitFor(() => expect(screen.getByText('good')).toBeInTheDocument());
|
||||
|
||||
const restoreButtons = screen.getAllByRole('button', { name: /^Restore$/i });
|
||||
expect(restoreButtons).toHaveLength(2);
|
||||
expect((restoreButtons[0] as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((restoreButtons[1] as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
const stackButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent?.includes('good') && btn.textContent?.includes('file'),
|
||||
);
|
||||
fireEvent.click(stackButtons[0]);
|
||||
await waitFor(() => expect(screen.getByText('Preview')).toBeInTheDocument());
|
||||
expect(screen.getByText('Download')).toBeInTheDocument();
|
||||
|
||||
const badStackButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent?.includes('bad') && btn.textContent?.includes('file'),
|
||||
);
|
||||
fireEvent.click(badStackButtons[0]);
|
||||
await waitFor(() => expect(screen.getByText('Unavailable')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user