mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 19:57:12 +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:
@@ -35,9 +35,14 @@ interface FleetSnapshot {
|
||||
has_documentation?: number;
|
||||
}
|
||||
|
||||
interface SnapshotStackFile {
|
||||
filename: string;
|
||||
content: string;
|
||||
type SnapshotStackFile =
|
||||
| { filename: string; content: string }
|
||||
| { filename: string; unavailable: true };
|
||||
|
||||
function isUnavailableSnapshotFile(
|
||||
file: SnapshotStackFile,
|
||||
): file is { filename: string; unavailable: true } {
|
||||
return 'unavailable' in file;
|
||||
}
|
||||
|
||||
interface SnapshotStack {
|
||||
@@ -70,6 +75,7 @@ interface SnapshotDocumentation {
|
||||
interface FleetSnapshotDetail extends FleetSnapshot {
|
||||
nodes: SnapshotNode[];
|
||||
documentation?: SnapshotDocumentation;
|
||||
fileDecryptWarnings?: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string }>;
|
||||
}
|
||||
|
||||
// Ordered labels for the read-only dossier block; only non-empty fields render.
|
||||
@@ -110,7 +116,7 @@ export default function FleetSnapshots() {
|
||||
|
||||
// Cloud-upload affordance is reachable when the saved provider is custom
|
||||
// (every tier) or sencho on a paid license. A downgraded admin whose
|
||||
// saved provider is still 'sencho' sees no upload button — they cannot
|
||||
// saved provider is still 'sencho' sees no upload button; they cannot
|
||||
// call POST /cloud-backup/upload/:id because gateForCurrentProvider would
|
||||
// 403 anyway, so the UI must not advertise an action that is gated away.
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
@@ -303,7 +309,11 @@ export default function FleetSnapshots() {
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => null);
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to restore stack.');
|
||||
if (res.status === 409 && err?.code === 'SNAPSHOT_FILE_UNAVAILABLE') {
|
||||
toast.error(err?.error || 'One or more snapshot files could not be decrypted.');
|
||||
} else {
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to restore stack.');
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Record<string, unknown> | null;
|
||||
@@ -314,6 +324,10 @@ export default function FleetSnapshots() {
|
||||
};
|
||||
|
||||
const handleDownloadFile = (stackName: string, file: SnapshotStackFile) => {
|
||||
if (isUnavailableSnapshotFile(file)) {
|
||||
toast.error('This snapshot file could not be decrypted.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const blob = new Blob([file.content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -505,6 +519,32 @@ export default function FleetSnapshots() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const warnings = selectedSnapshot.fileDecryptWarnings ?? [];
|
||||
if (warnings.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-warning/30 bg-warning/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
|
||||
<span className="text-sm font-medium text-warning">
|
||||
Some snapshot files could not be decrypted:
|
||||
</span>
|
||||
</div>
|
||||
<ul className="ml-6 space-y-1">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={`${w.nodeId}:${w.stackName}:${w.filename}:${i}`} className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">{w.nodeName}</span>
|
||||
{' / '}
|
||||
<span className="font-mono">{w.stackName}</span>
|
||||
{' / '}
|
||||
<span className="font-mono">{w.filename}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Partially captured stacks warning */}
|
||||
{(() => {
|
||||
const skipped = parseJsonArray<SkippedStack>(selectedSnapshot.skipped_stacks);
|
||||
@@ -587,6 +627,7 @@ export default function FleetSnapshots() {
|
||||
{node.stacks.map(stack => {
|
||||
const stackKey = `${node.nodeId}:${stack.stackName}`;
|
||||
const stackExpanded = expandedStacks.has(stackKey);
|
||||
const stackHasUnavailable = stack.files.some(isUnavailableSnapshotFile);
|
||||
const dossier = selectedSnapshot.documentation?.stacks
|
||||
.find(s => s.nodeId === node.nodeId && s.stackName === stack.stackName)?.dossier;
|
||||
return (
|
||||
@@ -615,6 +656,7 @@ export default function FleetSnapshots() {
|
||||
stackName={stack.stackName}
|
||||
hasDossier={!!dossier}
|
||||
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
|
||||
disabled={stackHasUnavailable}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
)}
|
||||
@@ -626,6 +668,19 @@ export default function FleetSnapshots() {
|
||||
{stack.files.map(file => {
|
||||
const fileKey = `${stackKey}:${file.filename}`;
|
||||
const showPreview = previewFiles.has(fileKey);
|
||||
if (isUnavailableSnapshotFile(file)) {
|
||||
return (
|
||||
<div key={fileKey}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md hover:bg-muted/50 transition-colors">
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-mono flex-1 truncate">{file.filename}</span>
|
||||
<Badge variant="outline" className="text-[10px] text-warning border-warning/40">
|
||||
Unavailable
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={fileKey}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md hover:bg-muted/50 transition-colors">
|
||||
@@ -911,12 +966,13 @@ export default function FleetSnapshots() {
|
||||
|
||||
// --- Restore Button Sub-Component ---
|
||||
|
||||
function RestoreButton({ nodeId, nodeName, stackName, hasDossier, restoring, onRestore }: {
|
||||
function RestoreButton({ nodeId, nodeName, stackName, hasDossier, restoring, disabled, onRestore }: {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
stackName: string;
|
||||
hasDossier: boolean;
|
||||
restoring: boolean;
|
||||
disabled?: boolean;
|
||||
onRestore: (nodeId: number, stackName: string, redeploy: boolean, restoreNotes: boolean) => Promise<void>;
|
||||
}) {
|
||||
const [redeploy, setRedeploy] = useState(false);
|
||||
@@ -929,7 +985,8 @@ function RestoreButton({ nodeId, nodeName, stackName, hasDossier, restoring, onR
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={restoring}
|
||||
disabled={restoring || disabled}
|
||||
title={disabled ? 'One or more files in this stack could not be decrypted' : undefined}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{restoring ? (
|
||||
|
||||
@@ -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