feat: add backup integrity verification
This commit is contained in:
@@ -11,6 +11,14 @@
|
||||
@UX_FEEDBACK Error toasts on failure (creation, schedule update, data load).
|
||||
@UX_RECOVERY Request timeout (30s) via AbortSignal; schedule optimistic update rollback.
|
||||
@UX_RECOVERY Auto-opens TaskDrawer on backup task creation.
|
||||
@UX_STATE VerificationIdle -> Backup files show neutral integrity state until checked.
|
||||
@UX_STATE VerificationLoading -> Verify action is busy and details panel shows progress.
|
||||
@UX_STATE VerificationSuccess -> Details panel shows archive and content hash matches.
|
||||
@UX_STATE VerificationError -> Restore-sensitive action remains unavailable and recovery is shown.
|
||||
@UX_FEEDBACK Verification result is shown inline and announced through aria-live.
|
||||
@UX_RECOVERY Retry verification or create a new backup when metadata is missing/corrupt.
|
||||
@UX_REACTIVITY Props -> $props(), LocalState -> $state(), Derived -> $derived().
|
||||
@UX_TEST: VerificationIdle -> {click: "Verify", expected: VerificationLoading -> VerificationSuccess}.
|
||||
@RATIONALE FileList replaced custom BackupList — shared component provides download, delete,
|
||||
bulk actions, search, sort, and pagination out of the box.
|
||||
-->
|
||||
@@ -22,12 +30,25 @@
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { openDrawerForTaskIfPreferred } from '$lib/stores/taskDrawer.svelte.js';
|
||||
import { Button, Card, Select, Input, ConfirmDialog } from '$lib/ui';
|
||||
import { listFiles, deleteFile } from '../../../services/storageService';
|
||||
import { listFiles, deleteFile, verifyBackup } from '../../../services/storageService';
|
||||
import FileList from '$lib/components/storage/FileList.svelte';
|
||||
import { appTimezone } from '$lib/stores/timezone.svelte.js';
|
||||
import {
|
||||
normalizeBackupIntegrityResult,
|
||||
type BackupIntegrityResult,
|
||||
type BackupIntegrityStatus,
|
||||
} from '../../../types/backup';
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
let files: any[] = $state([]);
|
||||
type BackupFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
category: string;
|
||||
size: number;
|
||||
created_at: string;
|
||||
mime_type: string;
|
||||
};
|
||||
let files: BackupFile[] = $state([]);
|
||||
let environments: any[] = $state([]);
|
||||
let selectedEnvId = $state('');
|
||||
let loading = $state(true);
|
||||
@@ -36,6 +57,8 @@
|
||||
let currentPath = $state('backups');
|
||||
let cronNextRun = $state<string | null>(null);
|
||||
let cronError = $state('');
|
||||
let verifyingPath = $state<string | null>(null);
|
||||
let selectedIntegrity = $state<{ file: BackupFile; result: BackupIntegrityResult } | null>(null);
|
||||
|
||||
// Schedule state
|
||||
let scheduleEnabled = $state(false);
|
||||
@@ -111,7 +134,7 @@
|
||||
}
|
||||
} catch { /* env context not available */ }
|
||||
}
|
||||
files = storageData || [];
|
||||
files = (storageData || []).filter((file: BackupFile) => !file.name.endsWith('.manifest.json'));
|
||||
log("BackupManager", "REFLECT", "Data loaded", { count: files.length });
|
||||
} catch (error: any) {
|
||||
log("BackupManager", "EXPLORE", "Load failed", {}, error?.message || 'Unknown');
|
||||
@@ -125,6 +148,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
// #region verifyIntegrity [C:3] [TYPE Function] [SEMANTICS backup,verify,integrity,hash]
|
||||
// @ingroup Components
|
||||
// @BRIEF Verify one backup archive server-side and expose safe hash details.
|
||||
// @PRE file identifies a stored backup archive.
|
||||
// @POST selectedIntegrity contains the latest server verification result.
|
||||
// @SIDE_EFFECT Calls the authenticated storage verification API and updates UI state.
|
||||
// @RELATION CALLS -> [verifyBackup]
|
||||
async function handleVerify(file: BackupFile): Promise<void> {
|
||||
if (verifyingPath) return;
|
||||
log("BackupManager", "REASON", "Verifying backup integrity", { path: file.path });
|
||||
verifyingPath = file.path;
|
||||
try {
|
||||
const raw = await verifyBackup(file.category, file.path);
|
||||
const result = normalizeBackupIntegrityResult(raw);
|
||||
selectedIntegrity = { file, result };
|
||||
log("BackupManager", "REFLECT", "Backup integrity verified", { path: file.path, status: result.status });
|
||||
addToast(statusLabel(result.status), result.status === 'ok' || result.status === 'verified' ? 'success' : 'error');
|
||||
} catch (error: unknown) {
|
||||
log("BackupManager", "EXPLORE", "Backup integrity verification failed", { path: file.path }, error instanceof Error ? error.message : 'Unknown error');
|
||||
selectedIntegrity = { file, result: { status: 'unknown', error: error instanceof Error ? error.message : 'Unknown error' } };
|
||||
addToast($t.storage.integrity.verify_failed, 'error');
|
||||
} finally {
|
||||
verifyingPath = null;
|
||||
}
|
||||
}
|
||||
// #endregion verifyIntegrity
|
||||
|
||||
function shortHash(value: unknown): string {
|
||||
const hash = typeof value === 'string' ? value : '';
|
||||
return hash ? `${hash.slice(0, 12)}…` : '—';
|
||||
}
|
||||
|
||||
function statusLabel(status: BackupIntegrityStatus | 'ok' | undefined): string {
|
||||
if (status === 'ok' || status === 'verified') return $t.storage.integrity.verified;
|
||||
if (status === 'manifest_missing') return $t.storage.integrity.missing;
|
||||
if (status === 'manifest_corrupted') return $t.storage.integrity.corrupted;
|
||||
if (status === 'integrity_violated') return $t.storage.integrity.violated;
|
||||
if (status === 'metadata_failed') return $t.storage.integrity.failed;
|
||||
return $t.storage.integrity.unchecked;
|
||||
}
|
||||
|
||||
function statusClass(status: BackupIntegrityStatus | 'ok' | undefined): string {
|
||||
if (status === 'ok' || status === 'verified') return 'bg-success-light text-success border-success-ring';
|
||||
if (status === 'manifest_corrupted' || status === 'integrity_violated') return 'bg-destructive-light text-destructive border-destructive-ring';
|
||||
if (status === 'manifest_missing' || status === 'metadata_failed') return 'bg-warning-light text-warning border-warning-ring';
|
||||
return 'bg-surface-muted text-text-muted border-border';
|
||||
}
|
||||
|
||||
// ── Backup trigger ──────────────────────────────────────────────
|
||||
async function handleCreateBackup() {
|
||||
if (!selectedEnvId) { addToast($t.tasks.select_env, 'error'); return; }
|
||||
@@ -299,16 +370,60 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-lg font-semibold text-text">{$t.storage.backups}</h2>
|
||||
<FileList
|
||||
<FileList
|
||||
{files}
|
||||
{currentPath}
|
||||
{loading}
|
||||
ondelete={handleDelete}
|
||||
onnavigate={handleNavigate}
|
||||
onnavigateup={handleNavigateUp}
|
||||
onbulkdelete={handleBulkDelete}
|
||||
/>
|
||||
</div>
|
||||
onbulkdelete={handleBulkDelete}
|
||||
onverify={handleVerify}
|
||||
showVerify={true}
|
||||
{verifyingPath}
|
||||
/>
|
||||
|
||||
{#if selectedIntegrity}
|
||||
<Card title={$t.storage.integrity.details_title}>
|
||||
<div class="space-y-3" aria-live="polite">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium text-text">{selectedIntegrity.file.name}</p>
|
||||
<p class="text-xs text-text-muted">{selectedIntegrity.file.path}</p>
|
||||
</div>
|
||||
<span class={`rounded-full border px-2.5 py-1 text-xs font-medium ${statusClass(selectedIntegrity.result.status)}`}>
|
||||
{statusLabel(selectedIntegrity.result.status)}
|
||||
</span>
|
||||
</div>
|
||||
<dl class="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-text-muted">{$t.storage.integrity.archive_hash}</dt>
|
||||
<dd class="font-mono text-text" title={selectedIntegrity.result.actual_sha256 || selectedIntegrity.result.archive_sha256 || ''}>
|
||||
{shortHash(selectedIntegrity.result.actual_sha256 || selectedIntegrity.result.archive_sha256)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">{$t.storage.integrity.content_hash}</dt>
|
||||
<dd class="font-mono text-text" title={selectedIntegrity.result.actual_content_hash || selectedIntegrity.result.content_hash || ''}>
|
||||
{shortHash(selectedIntegrity.result.actual_content_hash || selectedIntegrity.result.content_hash)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if selectedIntegrity.result.error}
|
||||
<p class="rounded-md border border-warning-ring bg-warning-light px-3 py-2 text-sm text-warning">
|
||||
{selectedIntegrity.result.error}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onclick={() => handleVerify(selectedIntegrity!.file)} disabled={Boolean(verifyingPath)} aria-busy={verifyingPath === selectedIntegrity.file.path}>
|
||||
{verifyingPath === selectedIntegrity.file.path ? $t.storage.integrity.verifying : $t.storage.integrity.retry}
|
||||
</Button>
|
||||
<p class="text-xs text-text-muted">{$t.storage.integrity.restore_hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
<!-- @UX_FEEDBACK Pagination "Showing X-Y of Z" -->
|
||||
<!-- @UX_FEEDBACK Search input filters in real-time -->
|
||||
<!-- @UX_RECOVERY Clear search to restore full list -->
|
||||
<!-- @UX_STATE Verifying -> Verify action disabled and aria-busy=true. -->
|
||||
<!-- @UX_FEEDBACK Verification result is rendered as a status badge in the parent. -->
|
||||
<!-- @UX_TEST: Table -> {click: "verify", expected: Parent receives file metadata}. -->
|
||||
<script lang="ts">
|
||||
import { downloadFile } from '../../../services/storageService.js';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
@@ -39,6 +42,9 @@
|
||||
onnavigate = (_path: string) => {},
|
||||
onnavigateup = () => {},
|
||||
onbulkdelete = (_files: FileEntry[]) => {},
|
||||
onverify = (_file: FileEntry) => {},
|
||||
showVerify = false,
|
||||
verifyingPath = null as string | null,
|
||||
} = $props();
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
@@ -324,6 +330,19 @@
|
||||
<Button size="sm" variant="ghost" onclick={() => handleDownload(file)}>
|
||||
{$t.storage.table.download || "Download"}
|
||||
</Button>
|
||||
{#if showVerify}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => onverify(file)}
|
||||
disabled={Boolean(verifyingPath)}
|
||||
aria-busy={verifyingPath === file.path}
|
||||
>
|
||||
{verifyingPath === file.path
|
||||
? $t.storage.integrity.verifying
|
||||
: $t.storage.verify}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
<Button size="sm" variant="ghost"
|
||||
onclick={() => ondelete({ category: file.category, path: file.path, name: file.name })}>
|
||||
|
||||
@@ -14,6 +14,22 @@
|
||||
"drag_drop": "or drag and drop",
|
||||
"supported_formats": "ZIP, YAML, JSON up to 50MB",
|
||||
"uploading": "Uploading...",
|
||||
"verify": "Verify",
|
||||
"integrity": {
|
||||
"details_title": "Backup integrity",
|
||||
"archive_hash": "Archive SHA-256",
|
||||
"content_hash": "Content hash",
|
||||
"verified": "Verified",
|
||||
"missing": "Verification required",
|
||||
"corrupted": "Manifest corrupted",
|
||||
"violated": "Integrity check failed",
|
||||
"failed": "Metadata unavailable",
|
||||
"unchecked": "Not checked",
|
||||
"verifying": "Verifying...",
|
||||
"retry": "Check again",
|
||||
"verify_failed": "Backup verification failed.",
|
||||
"restore_hint": "Restore is safe only after a successful verification."
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"category": "Category",
|
||||
|
||||
@@ -14,6 +14,22 @@
|
||||
"drag_drop": "или перетащите сюда",
|
||||
"supported_formats": "ZIP, YAML, JSON до 50МБ",
|
||||
"uploading": "Загрузка...",
|
||||
"verify": "Проверить",
|
||||
"integrity": {
|
||||
"details_title": "Целостность бэкапа",
|
||||
"archive_hash": "SHA-256 архива",
|
||||
"content_hash": "Хеш содержимого",
|
||||
"verified": "Проверен",
|
||||
"missing": "Требуется проверка",
|
||||
"corrupted": "Manifest повреждён",
|
||||
"violated": "Целостность нарушена",
|
||||
"failed": "Метаданные недоступны",
|
||||
"unchecked": "Не проверен",
|
||||
"verifying": "Проверка...",
|
||||
"retry": "Проверить снова",
|
||||
"verify_failed": "Не удалось проверить бэкап.",
|
||||
"restore_hint": "Восстановление безопасно только после успешной проверки."
|
||||
},
|
||||
"table": {
|
||||
"name": "Имя",
|
||||
"category": "Категория",
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getAssistantHistory,
|
||||
deleteAssistantConversation,
|
||||
} from "$lib/api/assistant.js";
|
||||
import { fetchApi } from "$lib/api";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
import { log } from "$lib/cot-logger";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
@@ -923,14 +924,13 @@ export class AgentChatModel {
|
||||
/** Check LLM provider connectivity via backend endpoint. */
|
||||
async checkLlmStatus(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/api/agent/llm-status");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
this.llmStatus = data.status || "unknown";
|
||||
if (data.status !== "ok" && !this.llmBannerDismissed) {
|
||||
this.llmBannerMessage = this._bannerMessageForStatus(data.status);
|
||||
this._startRetryCountdown(data.retry_after_s || 30);
|
||||
} else if (data.status === "ok") {
|
||||
const data = await fetchApi<Record<string, unknown>>("/agent/llm-status", { suppressToast: true });
|
||||
const status = typeof data.status === "string" ? data.status : "unknown";
|
||||
this.llmStatus = status;
|
||||
if (status !== "ok" && !this.llmBannerDismissed) {
|
||||
this.llmBannerMessage = this._bannerMessageForStatus(status);
|
||||
this._startRetryCountdown(typeof data.retry_after_s === "number" ? data.retry_after_s : 30);
|
||||
} else if (status === "ok") {
|
||||
this.llmBannerDismissed = false;
|
||||
this.llmRetryCountdown = 0;
|
||||
this.llmBannerMessage = "";
|
||||
|
||||
@@ -27,6 +27,10 @@ vi.mock("$lib/api/assistant.js", () => ({
|
||||
deleteAssistantConversation: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/api", () => ({
|
||||
fetchApi: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock $lib/stores/assistantChat.svelte.js
|
||||
vi.mock("$lib/stores/assistantChat.svelte.js", () => ({
|
||||
assistantChatStore: { value: { isOpen: false, conversationId: null } },
|
||||
@@ -44,6 +48,7 @@ vi.mock("$lib/cot-logger", () => ({
|
||||
}));
|
||||
|
||||
import { AgentChatModel } from "../AgentChatModel.svelte.ts";
|
||||
import { fetchApi } from "$lib/api";
|
||||
|
||||
// #region TestAgentChat.Model.StateMachine [C:2] [TYPE Function] [SEMANTICS test,model,state]
|
||||
// @BRIEF State transition tests: idle->streaming, streaming->idle, awaiting_confirmation->idle.
|
||||
@@ -1209,13 +1214,13 @@ describe("AgentChatModel — LLM Status", () => {
|
||||
});
|
||||
|
||||
it("checkLlmStatus handles fetch failure", async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network fail"));
|
||||
vi.mocked(fetchApi).mockRejectedValue(new Error("Network fail"));
|
||||
await model.checkLlmStatus();
|
||||
expect(model.llmStatus).toBe("unknown");
|
||||
});
|
||||
|
||||
it("checkLlmStatus handles HTTP error status", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
||||
vi.mocked(fetchApi).mockRejectedValue(new Error("HTTP 503"));
|
||||
await model.checkLlmStatus();
|
||||
expect(model.llmStatus).toBe("unknown");
|
||||
});
|
||||
@@ -1224,10 +1229,7 @@ describe("AgentChatModel — LLM Status", () => {
|
||||
model.llmBannerDismissed = true;
|
||||
model.llmRetryCountdown = 10;
|
||||
model.llmBannerMessage = "old error";
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: "ok" }),
|
||||
});
|
||||
vi.mocked(fetchApi).mockResolvedValue({ status: "ok" });
|
||||
await model.checkLlmStatus();
|
||||
expect(model.llmStatus).toBe("ok");
|
||||
expect(model.llmBannerDismissed).toBe(false);
|
||||
@@ -1237,10 +1239,7 @@ describe("AgentChatModel — LLM Status", () => {
|
||||
|
||||
it("checkLlmStatus sets banner for non-ok status", async () => {
|
||||
model.llmBannerDismissed = false;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: "timeout", retry_after_s: 30 }),
|
||||
});
|
||||
vi.mocked(fetchApi).mockResolvedValue({ status: "timeout", retry_after_s: 30 });
|
||||
const bannerSpy = vi.spyOn(model as any, "_bannerMessageForStatus").mockReturnValue("LLM timeout banner");
|
||||
await model.checkLlmStatus();
|
||||
expect(model.llmBannerMessage).toBe("LLM timeout banner");
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
});
|
||||
|
||||
let isLoginPage = $derived(page.url.pathname === '/login');
|
||||
let isAgentPage = $derived(page.url.pathname === '/agent');
|
||||
let isExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
|
||||
let isProductionContext = $derived($isProductionContextStore);
|
||||
let selectedEnvironment = $derived($selectedEnvironmentStore);
|
||||
@@ -117,7 +118,9 @@
|
||||
|
||||
<!-- Global Task Drawer -->
|
||||
<TaskDrawer />
|
||||
<AssistantChatPanel />
|
||||
{#if !isAgentPage}
|
||||
<AssistantChatPanel />
|
||||
{/if}
|
||||
</ProtectedRoute>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
// @LAYER Service
|
||||
// @RELATION DEPENDS_ON -> [ApiModule]
|
||||
|
||||
import { deleteApi, fetchApi, fetchApiBlob, requestApi, uploadFile as uploadApiFile } from '$lib/api';
|
||||
import type { BackupIntegrityApiResponse } from '../types/backup';
|
||||
|
||||
const API_BASE = '/api/storage';
|
||||
|
||||
// #region storageService:Module [TYPE Function]
|
||||
// @PURPOSE: Default purpose
|
||||
// @RELATION USES -> [EXT:frontend:App]
|
||||
@@ -27,31 +32,20 @@ interface UploadResponse {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ErrorDetail {
|
||||
detail?: string;
|
||||
[key: string]: unknown;
|
||||
// #region verifyBackup [C:2] [TYPE Function] [SEMANTICS backup,verify,integrity]
|
||||
// @BRIEF Verify a backup archive through the authenticated storage API.
|
||||
// @PRE category and path identify a stored archive.
|
||||
// @POST Returns server-side archive and semantic hash comparison.
|
||||
// @RELATION DEPENDS_ON -> [StorageApi]
|
||||
export async function verifyBackup(category: string, path: string): Promise<BackupIntegrityApiResponse> {
|
||||
return requestApi<BackupIntegrityApiResponse>(
|
||||
`/storage/verify/${encodeURIComponent(category)}/${encodeStoragePath(path)}`,
|
||||
'GET',
|
||||
null,
|
||||
{ suppressToast: true },
|
||||
);
|
||||
}
|
||||
|
||||
const API_BASE = '/api/storage';
|
||||
|
||||
// #region getStorageAuthHeaders:Function [TYPE Function]
|
||||
/**
|
||||
* @purpose Returns headers with Authorization for storage API calls.
|
||||
* @returns Headers object with Authorization if token exists.
|
||||
* @NOTE Unlike api.js getAuthHeaders, this doesn't set Content-Type
|
||||
* to allow FormData to set its own multipart boundary.
|
||||
*/
|
||||
function getStorageAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
// #endregion getStorageAuthHeaders:Function
|
||||
// #endregion verifyBackup
|
||||
|
||||
// #region encodeStoragePath:Function [TYPE Function]
|
||||
/**
|
||||
@@ -85,13 +79,7 @@ export async function listFiles(category?: string, path?: string): Promise<Store
|
||||
if (path) {
|
||||
params.append('path', path);
|
||||
}
|
||||
const response = await fetch(`${API_BASE}/files?${params.toString()}`, {
|
||||
headers: getStorageAuthHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch files: ${response.statusText}`);
|
||||
}
|
||||
return await response.json();
|
||||
return fetchApi<StoredFile[]>(`/storage/files?${params.toString()}`);
|
||||
}
|
||||
// #endregion listFiles:Function
|
||||
|
||||
@@ -106,24 +94,10 @@ export async function listFiles(category?: string, path?: string): Promise<Store
|
||||
* @POST Returns a promise resolving to the metadata of the uploaded file.
|
||||
*/
|
||||
export async function uploadFile(file: File, category: string, path?: string): Promise<UploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', category);
|
||||
if (path) {
|
||||
formData.append('path', path);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/upload`, {
|
||||
method: 'POST',
|
||||
headers: getStorageAuthHeaders(),
|
||||
body: formData
|
||||
return uploadApiFile<UploadResponse>('/storage/upload', file, {
|
||||
category,
|
||||
...(path ? { path } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorDetail = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Failed to upload file: ${response.statusText}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
// #endregion uploadFile:Function
|
||||
|
||||
@@ -137,15 +111,7 @@ export async function uploadFile(file: File, category: string, path?: string): P
|
||||
* @POST The specified file or directory is removed from storage.
|
||||
*/
|
||||
export async function deleteFile(category: string, path: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/files/${category}/${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: getStorageAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorDetail = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Failed to delete: ${response.statusText}`);
|
||||
}
|
||||
await deleteApi(`/storage/files/${encodeURIComponent(category)}/${encodeStoragePath(path)}`);
|
||||
}
|
||||
// #endregion deleteFile:Function
|
||||
|
||||
@@ -178,16 +144,7 @@ export function downloadFileUrl(category: string, path: string): string {
|
||||
* @POST Browser download is triggered or an Error is thrown.
|
||||
*/
|
||||
export async function downloadFile(category: string, path: string, filename?: string): Promise<void> {
|
||||
const response = await fetch(downloadFileUrl(category, path), {
|
||||
headers: getStorageAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorDetail = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Failed to download file: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blob = await fetchApiBlob(`/storage/download/${encodeURIComponent(category)}/${encodeStoragePath(path)}`);
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
|
||||
81
frontend/src/types/__tests__/backup.test.ts
Normal file
81
frontend/src/types/__tests__/backup.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// #region Test.BackupTypes [C:2] [TYPE Module] [SEMANTICS test,backup,integrity,normalization]
|
||||
// @BRIEF Tests for backup integrity DTO normalization at the frontend API boundary.
|
||||
// @RELATION BINDS_TO -> [BackupTypes]
|
||||
// @TEST_INVARIANT Known backend statuses are preserved without trusting untyped payloads.
|
||||
// @TEST_INVARIANT Unknown or malformed statuses normalize to unknown.
|
||||
// @TEST_EDGE: errors_array -> String errors are joined; non-string entries are ignored.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeBackupIntegrityResult } from '../backup';
|
||||
|
||||
describe('normalizeBackupIntegrityResult', () => {
|
||||
it('maps a successful backend response to the frontend DTO', () => {
|
||||
expect(normalizeBackupIntegrityResult({
|
||||
status: 'ok',
|
||||
manifest_sha256: 'manifest-archive-hash',
|
||||
actual_sha256: 'actual-archive-hash',
|
||||
manifest_content_hash: 'manifest-content-hash',
|
||||
actual_content_hash: 'actual-content-hash',
|
||||
manifest: 'backup.manifest.json',
|
||||
})).toEqual({
|
||||
status: 'ok',
|
||||
archive_sha256: 'manifest-archive-hash',
|
||||
actual_sha256: 'actual-archive-hash',
|
||||
content_hash: 'manifest-content-hash',
|
||||
actual_content_hash: 'actual-content-hash',
|
||||
manifest_path: 'backup.manifest.json',
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves every supported verification status', () => {
|
||||
const statuses = [
|
||||
'verified',
|
||||
'manifest_missing',
|
||||
'manifest_corrupted',
|
||||
'integrity_violated',
|
||||
'metadata_failed',
|
||||
'no_archive',
|
||||
'unknown',
|
||||
] as const;
|
||||
|
||||
for (const status of statuses) {
|
||||
expect(normalizeBackupIntegrityResult({ status }).status).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes malformed payloads and joins backend errors safely', () => {
|
||||
expect(normalizeBackupIntegrityResult({
|
||||
status: 'unexpected',
|
||||
archive_sha256: 123,
|
||||
errors: ['archive_sha256_mismatch', 42, 'content_hash_mismatch'],
|
||||
})).toEqual({
|
||||
status: 'unknown',
|
||||
archive_sha256: null,
|
||||
actual_sha256: null,
|
||||
content_hash: null,
|
||||
actual_content_hash: null,
|
||||
manifest_path: null,
|
||||
error: 'archive_sha256_mismatch, content_hash_mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the canonical field names when manifest aliases are absent', () => {
|
||||
expect(normalizeBackupIntegrityResult({
|
||||
status: 'verified',
|
||||
archive_sha256: 'archive-hash',
|
||||
content_hash: 'content-hash',
|
||||
manifest_path: 'manifest.json',
|
||||
error: 'already checked',
|
||||
})).toEqual({
|
||||
status: 'verified',
|
||||
archive_sha256: 'archive-hash',
|
||||
actual_sha256: null,
|
||||
content_hash: 'content-hash',
|
||||
actual_content_hash: null,
|
||||
manifest_path: 'manifest.json',
|
||||
error: 'already checked',
|
||||
});
|
||||
});
|
||||
});
|
||||
// #endregion Test.BackupTypes
|
||||
@@ -45,7 +45,89 @@ export interface BackupTaskResult {
|
||||
total_dashboards: number;
|
||||
backed_up_dashboards: number;
|
||||
failed_dashboards: number;
|
||||
dashboards: Array<{ id: number; title: string; path: string }>;
|
||||
dashboards: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
path: string;
|
||||
archive_sha256?: string | null;
|
||||
content_hash?: string | null;
|
||||
integrity_status?: BackupIntegrityStatus;
|
||||
manifest_path?: string | null;
|
||||
}>;
|
||||
failures: Array<{ id: number; title: string; error: string }>;
|
||||
}
|
||||
|
||||
export type BackupIntegrityStatus =
|
||||
| 'ok'
|
||||
| 'verified'
|
||||
| 'manifest_missing'
|
||||
| 'manifest_corrupted'
|
||||
| 'integrity_violated'
|
||||
| 'metadata_failed'
|
||||
| 'no_archive'
|
||||
| 'unknown';
|
||||
|
||||
export interface BackupIntegrityResult {
|
||||
status: BackupIntegrityStatus;
|
||||
archive_sha256?: string | null;
|
||||
actual_sha256?: string | null;
|
||||
content_hash?: string | null;
|
||||
actual_content_hash?: string | null;
|
||||
manifest_path?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface BackupIntegrityApiResponse {
|
||||
status?: unknown;
|
||||
manifest_sha256?: unknown;
|
||||
archive_sha256?: unknown;
|
||||
actual_sha256?: unknown;
|
||||
manifest_content_hash?: unknown;
|
||||
content_hash?: unknown;
|
||||
actual_content_hash?: unknown;
|
||||
manifest?: unknown;
|
||||
manifest_path?: unknown;
|
||||
error?: unknown;
|
||||
errors?: unknown;
|
||||
}
|
||||
|
||||
// #region normalizeBackupIntegrityResult [C:2] [TYPE Function] [SEMANTICS backup,verify,integrity,normalization]
|
||||
// @BRIEF Normalize an untrusted verification response into the frontend integrity DTO.
|
||||
// @PRE raw may contain an unknown backend response shape.
|
||||
// @POST Returns a typed result with a safe status and nullable hash/error fields.
|
||||
// @DATA_CONTRACT Input: unknown API payload -> Output: BackupIntegrityResult
|
||||
export function isBackupIntegrityStatus(value: unknown): value is BackupIntegrityStatus {
|
||||
return [
|
||||
'ok',
|
||||
'verified',
|
||||
'manifest_missing',
|
||||
'manifest_corrupted',
|
||||
'integrity_violated',
|
||||
'metadata_failed',
|
||||
'no_archive',
|
||||
'unknown',
|
||||
].includes(String(value));
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeBackupIntegrityResult(raw: unknown): BackupIntegrityResult {
|
||||
const payload = (raw && typeof raw === 'object' ? raw : {}) as BackupIntegrityApiResponse;
|
||||
const errors = Array.isArray(payload.errors)
|
||||
? payload.errors.filter((item): item is string => typeof item === 'string').join(', ')
|
||||
: null;
|
||||
|
||||
return {
|
||||
status: isBackupIntegrityStatus(payload.status) ? payload.status : 'unknown',
|
||||
archive_sha256: readString(payload.manifest_sha256) ?? readString(payload.archive_sha256),
|
||||
actual_sha256: readString(payload.actual_sha256),
|
||||
content_hash: readString(payload.manifest_content_hash) ?? readString(payload.content_hash),
|
||||
actual_content_hash: readString(payload.actual_content_hash),
|
||||
manifest_path: readString(payload.manifest) ?? readString(payload.manifest_path),
|
||||
error: readString(payload.error) ?? errors,
|
||||
};
|
||||
}
|
||||
// #endregion normalizeBackupIntegrityResult
|
||||
// #endregion BackupTypes
|
||||
|
||||
Reference in New Issue
Block a user