From 538da1f2b210d0504ee9d26e705cd2863ac6ceea Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 2 Sep 2026 15:24:33 +0900 Subject: [PATCH] fix: validate workspace profile responses --- web/workspace/deno.json | 2 +- .../src/lib/workspace/settings/model.ts | 8 +- .../src/lib/workspace/settings/profile-api.ts | 358 +++++++++++++++--- .../lib/workspace/settings/profile-types.ts | 52 --- .../settings/profiles/+page.svelte | 2 +- .../settings/workspace/+page.svelte | 14 +- web/workspace/tests/profile-api.test.ts | 213 +++++++++-- 7 files changed, 506 insertions(+), 143 deletions(-) delete mode 100644 web/workspace/src/lib/workspace/settings/profile-types.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index c416e66d..f6bb8c2a 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", + "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/workspace/settings/model.ts b/web/workspace/src/lib/workspace/settings/model.ts index 97c1f68c..59f69f3c 100644 --- a/web/workspace/src/lib/workspace/settings/model.ts +++ b/web/workspace/src/lib/workspace/settings/model.ts @@ -1,8 +1,6 @@ -export type Diagnostic = { - severity: "info" | "warning" | "error"; - code: string; - message: string; -}; +import type { Diagnostic as WorkspaceApiDiagnostic } from "$lib/generated/workspace-api"; + +export type Diagnostic = WorkspaceApiDiagnostic; export type SettingsSectionId = | "runtimes" diff --git a/web/workspace/src/lib/workspace/settings/profile-api.ts b/web/workspace/src/lib/workspace/settings/profile-api.ts index faea8804..e0ff66e7 100644 --- a/web/workspace/src/lib/workspace/settings/profile-api.ts +++ b/web/workspace/src/lib/workspace/settings/profile-api.ts @@ -1,69 +1,335 @@ import type { + Diagnostic, + DiagnosticSeverity, ProfileSettingsResponse, + UpdateWorkspaceMetadataRequest, WorkspaceMetadataMutationResponse, WorkspaceMetadataSettingsResponse, -} from "./profile-types"; + WorkspaceProfileSourceProvenance, + WorkspaceProfileSourceSummary, + WorkspaceProfileSummary, +} from "$lib/generated/workspace-api"; -export type WorkspaceProfileApi = { - getMetadata(workspaceId: string): Promise; - updateMetadata( - workspaceId: string, - displayName: string, - expectedRevision: string, - ): Promise; - getProfiles(workspaceId: string): Promise; -}; - -async function requestJson( - input: RequestInfo | URL, - init?: RequestInit, -): Promise { - const response = await fetch(input, init); - if (!response.ok) { - throw new Error(`request failed: ${response.status}`); +export class ProfileApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "ProfileApiError"; } - return (await response.json()) as T; } -export async function fetchWorkspaceMetadataSettings( +type JsonRecord = Record; + +function record(value: unknown, context: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return value as JsonRecord; +} + +function exactKeys( + value: JsonRecord, + required: readonly string[], + optional: readonly string[], + context: string, +): void { + const allowed = new Set([...required, ...optional]); + if ( + required.some((key) => !(key in value)) || + Object.keys(value).some((key) => !allowed.has(key)) + ) { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } +} + +function stringValue(value: unknown, context: string): string { + if (typeof value !== "string") { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return value; +} + +function booleanValue(value: unknown, context: string): boolean { + if (typeof value !== "boolean") { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return value; +} + +function optionalString( + value: unknown, + context: string, +): string | null | undefined { + if (value === undefined || value === null) return value; + return stringValue(value, context); +} + +function optionalRevision( + value: unknown, + context: string, +): number | null | undefined { + if (value === undefined || value === null) return value; + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return value as number; +} + +function arrayValue( + value: unknown, + parser: (item: unknown) => T, + context: string, +): T[] { + if (!Array.isArray(value)) { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return value.map(parser); +} + +function parseDiagnostic(value: unknown): Diagnostic { + const item = record(value, "Workspace settings"); + exactKeys(item, ["code", "severity", "message"], [], "Workspace settings"); + const severity = stringValue(item.severity, "Workspace settings"); + if (!(["info", "warning", "error"] as string[]).includes(severity)) { + throw new ProfileApiError( + "Workspace settings returned an invalid response.", + 502, + ); + } + return { + code: stringValue(item.code, "Workspace settings"), + severity: severity as DiagnosticSeverity, + message: stringValue(item.message, "Workspace settings"), + }; +} + +export function parseWorkspaceMetadataSettingsResponse( + value: unknown, +): WorkspaceMetadataSettingsResponse { + const item = record(value, "Workspace metadata"); + exactKeys( + item, + [ + "workspace_id", + "display_name", + "created_at", + "revision", + "source", + "diagnostics", + ], + [], + "Workspace metadata", + ); + return { + workspace_id: stringValue(item.workspace_id, "Workspace metadata"), + display_name: stringValue(item.display_name, "Workspace metadata"), + created_at: stringValue(item.created_at, "Workspace metadata"), + revision: stringValue(item.revision, "Workspace metadata"), + source: stringValue(item.source, "Workspace metadata"), + diagnostics: arrayValue( + item.diagnostics, + parseDiagnostic, + "Workspace metadata", + ), + }; +} + +export function parseWorkspaceMetadataMutationResponse( + value: unknown, +): WorkspaceMetadataMutationResponse { + const item = record(value, "Workspace metadata update"); + exactKeys( + item, + ["workspace", "diagnostics"], + [], + "Workspace metadata update", + ); + return { + workspace: parseWorkspaceMetadataSettingsResponse(item.workspace), + diagnostics: arrayValue( + item.diagnostics, + parseDiagnostic, + "Workspace metadata update", + ), + }; +} + +function parseWorkspaceProfileSummary(value: unknown): WorkspaceProfileSummary { + const item = record(value, "Profile catalog"); + exactKeys( + item, + [ + "profile_id", + "selector", + "label", + "source_kind", + "editable", + "is_default", + "diagnostics", + ], + ["profile_source_id", "description"], + "Profile catalog", + ); + return { + profile_id: stringValue(item.profile_id, "Profile catalog"), + selector: stringValue(item.selector, "Profile catalog"), + label: stringValue(item.label, "Profile catalog"), + source_kind: stringValue(item.source_kind, "Profile catalog"), + profile_source_id: optionalString( + item.profile_source_id, + "Profile catalog", + ), + description: optionalString(item.description, "Profile catalog"), + editable: booleanValue(item.editable, "Profile catalog"), + is_default: booleanValue(item.is_default, "Profile catalog"), + diagnostics: arrayValue( + item.diagnostics, + parseDiagnostic, + "Profile catalog", + ), + }; +} + +function parseWorkspaceProfileSourceSummary( + value: unknown, +): WorkspaceProfileSourceSummary { + const item = record(value, "Profile source catalog"); + exactKeys( + item, + [ + "profile_source_id", + "display_path", + "kind", + "content_type", + "content_digest", + "provenance", + "editable", + "revision", + "size_bytes", + "diagnostics", + ], + [], + "Profile source catalog", + ); + const provenance = stringValue(item.provenance, "Profile source catalog"); + if (provenance !== "project_profile_source_tree") { + throw new ProfileApiError( + "Profile source catalog returned an invalid response.", + 502, + ); + } + const sizeBytes = optionalRevision(item.size_bytes, "Profile source catalog"); + if (sizeBytes === undefined || sizeBytes === null) { + throw new ProfileApiError( + "Profile source catalog returned an invalid response.", + 502, + ); + } + return { + profile_source_id: stringValue( + item.profile_source_id, + "Profile source catalog", + ), + display_path: stringValue(item.display_path, "Profile source catalog"), + kind: stringValue(item.kind, "Profile source catalog"), + content_type: stringValue(item.content_type, "Profile source catalog"), + content_digest: stringValue(item.content_digest, "Profile source catalog"), + provenance: provenance as WorkspaceProfileSourceProvenance, + editable: booleanValue(item.editable, "Profile source catalog"), + revision: stringValue(item.revision, "Profile source catalog"), + size_bytes: sizeBytes, + diagnostics: arrayValue( + item.diagnostics, + parseDiagnostic, + "Profile source catalog", + ), + }; +} + +export function parseProfileSettingsResponse( + value: unknown, +): ProfileSettingsResponse { + const item = record(value, "Profile settings"); + exactKeys( + item, + ["workspace_id", "registry_revision", "profiles", "sources", "diagnostics"], + ["config_revision", "tree_digest", "projection_digest", "default_profile"], + "Profile settings", + ); + return { + workspace_id: stringValue(item.workspace_id, "Profile settings"), + registry_revision: stringValue(item.registry_revision, "Profile settings"), + config_revision: optionalRevision(item.config_revision, "Profile settings"), + tree_digest: optionalString(item.tree_digest, "Profile settings"), + projection_digest: optionalString( + item.projection_digest, + "Profile settings", + ), + default_profile: optionalString(item.default_profile, "Profile settings"), + profiles: arrayValue( + item.profiles, + parseWorkspaceProfileSummary, + "Profile settings", + ), + sources: arrayValue( + item.sources, + parseWorkspaceProfileSourceSummary, + "Profile settings", + ), + diagnostics: arrayValue( + item.diagnostics, + parseDiagnostic, + "Profile settings", + ), + }; +} + +async function parseResponse( + response: Response, + parser: (value: unknown) => T, +): Promise { + if (!response.ok) { + throw new ProfileApiError( + (await response.text()) || response.statusText, + response.status, + ); + } + return parser(await response.json() as unknown); +} + +export async function fetchWorkspaceMetadata( workspaceId: string, ): Promise { - return await requestJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, + return await parseResponse( + await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`), + parseWorkspaceMetadataSettingsResponse, ); } -export async function updateWorkspaceMetadataSettings( +export async function updateWorkspaceMetadata( workspaceId: string, - request: { display_name: string; revision: string }, + request: UpdateWorkspaceMetadataRequest, ): Promise { - return await requestJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(request), - }, + return await parseResponse( + await fetch( + `/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }, + ), + parseWorkspaceMetadataMutationResponse, ); } export async function fetchProfileSettings( workspaceId: string, ): Promise { - return await requestJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, + return await parseResponse( + await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`), + parseProfileSettingsResponse, ); } - -export function createWorkspaceProfileApi(): WorkspaceProfileApi { - return { - getMetadata: fetchWorkspaceMetadataSettings, - async updateMetadata(workspaceId, displayName, expectedRevision) { - return await updateWorkspaceMetadataSettings(workspaceId, { - display_name: displayName, - revision: expectedRevision, - }); - }, - getProfiles: fetchProfileSettings, - }; -} diff --git a/web/workspace/src/lib/workspace/settings/profile-types.ts b/web/workspace/src/lib/workspace/settings/profile-types.ts deleted file mode 100644 index 85fdf323..00000000 --- a/web/workspace/src/lib/workspace/settings/profile-types.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Diagnostic } from "./model"; - -export type WorkspaceMetadataSettingsResponse = { - workspace_id: string; - display_name: string; - created_at: string; - revision: string; - source: string; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceMetadataMutationResponse = { - workspace: WorkspaceMetadataSettingsResponse; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceProfileSummary = { - profile_id: string; - selector: string; - label: string; - source_kind: "builtin" | "project" | string; - profile_source_id?: string | null; - description?: string | null; - editable: boolean; - is_default: boolean; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceProfileSourceSummary = { - profile_source_id: string; - display_path: string; - kind: "virtual_config" | string; - content_type: string; - content_digest: string; - provenance: "project_profile_source_tree" | string; - editable: boolean; - revision: string; - size_bytes: number; - diagnostics: Diagnostic[]; -}; - -export type ProfileSettingsResponse = { - workspace_id: string; - registry_revision: string; - config_revision?: number | null; - tree_digest?: string | null; - projection_digest?: string | null; - default_profile?: string | null; - profiles: WorkspaceProfileSummary[]; - sources: WorkspaceProfileSourceSummary[]; - diagnostics: Diagnostic[]; -}; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte index 87ae865a..71a1bf0b 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte @@ -1,8 +1,8 @@