fix: validate workspace profile responses
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
export type Diagnostic = {
|
import type { Diagnostic as WorkspaceApiDiagnostic } from "$lib/generated/workspace-api";
|
||||||
severity: "info" | "warning" | "error";
|
|
||||||
code: string;
|
export type Diagnostic = WorkspaceApiDiagnostic;
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SettingsSectionId =
|
export type SettingsSectionId =
|
||||||
| "runtimes"
|
| "runtimes"
|
||||||
|
|||||||
@@ -1,69 +1,335 @@
|
|||||||
import type {
|
import type {
|
||||||
|
Diagnostic,
|
||||||
|
DiagnosticSeverity,
|
||||||
ProfileSettingsResponse,
|
ProfileSettingsResponse,
|
||||||
|
UpdateWorkspaceMetadataRequest,
|
||||||
WorkspaceMetadataMutationResponse,
|
WorkspaceMetadataMutationResponse,
|
||||||
WorkspaceMetadataSettingsResponse,
|
WorkspaceMetadataSettingsResponse,
|
||||||
} from "./profile-types";
|
WorkspaceProfileSourceProvenance,
|
||||||
|
WorkspaceProfileSourceSummary,
|
||||||
|
WorkspaceProfileSummary,
|
||||||
|
} from "$lib/generated/workspace-api";
|
||||||
|
|
||||||
export type WorkspaceProfileApi = {
|
export class ProfileApiError extends Error {
|
||||||
getMetadata(workspaceId: string): Promise<WorkspaceMetadataSettingsResponse>;
|
constructor(
|
||||||
updateMetadata(
|
message: string,
|
||||||
workspaceId: string,
|
readonly status: number,
|
||||||
displayName: string,
|
) {
|
||||||
expectedRevision: string,
|
super(message);
|
||||||
): Promise<WorkspaceMetadataMutationResponse>;
|
this.name = "ProfileApiError";
|
||||||
getProfiles(workspaceId: string): Promise<ProfileSettingsResponse>;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function requestJson<T>(
|
|
||||||
input: RequestInfo | URL,
|
|
||||||
init?: RequestInit,
|
|
||||||
): Promise<T> {
|
|
||||||
const response = await fetch(input, init);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`request failed: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
return (await response.json()) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWorkspaceMetadataSettings(
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
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<T>(
|
||||||
|
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<T>(
|
||||||
|
response: Response,
|
||||||
|
parser: (value: unknown) => T,
|
||||||
|
): Promise<T> {
|
||||||
|
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,
|
workspaceId: string,
|
||||||
): Promise<WorkspaceMetadataSettingsResponse> {
|
): Promise<WorkspaceMetadataSettingsResponse> {
|
||||||
return await requestJson<WorkspaceMetadataSettingsResponse>(
|
return await parseResponse(
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`),
|
||||||
|
parseWorkspaceMetadataSettingsResponse,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateWorkspaceMetadataSettings(
|
export async function updateWorkspaceMetadata(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
request: { display_name: string; revision: string },
|
request: UpdateWorkspaceMetadataRequest,
|
||||||
): Promise<WorkspaceMetadataMutationResponse> {
|
): Promise<WorkspaceMetadataMutationResponse> {
|
||||||
return await requestJson<WorkspaceMetadataMutationResponse>(
|
return await parseResponse(
|
||||||
|
await fetch(
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
parseWorkspaceMetadataMutationResponse,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProfileSettings(
|
export async function fetchProfileSettings(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<ProfileSettingsResponse> {
|
): Promise<ProfileSettingsResponse> {
|
||||||
return await requestJson<ProfileSettingsResponse>(
|
return await parseResponse(
|
||||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import DiagnosticsList from "$lib/workspace/settings/DiagnosticsList.svelte";
|
import DiagnosticsList from "$lib/workspace/settings/DiagnosticsList.svelte";
|
||||||
import { settingsSectionHref } from "$lib/workspace/settings/model";
|
import { settingsSectionHref } from "$lib/workspace/settings/model";
|
||||||
|
import type { ProfileSettingsResponse } from "$lib/generated/workspace-api";
|
||||||
import { fetchProfileSettings } from "$lib/workspace/settings/profile-api";
|
import { fetchProfileSettings } from "$lib/workspace/settings/profile-api";
|
||||||
import type { ProfileSettingsResponse } from "$lib/workspace/settings/profile-types";
|
|
||||||
import type { PageProps } from "./$types";
|
import type { PageProps } from "./$types";
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type {
|
||||||
|
Diagnostic,
|
||||||
|
WorkspaceMetadataSettingsResponse,
|
||||||
|
} from '$lib/generated/workspace-api';
|
||||||
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
||||||
import {
|
import {
|
||||||
fetchWorkspaceMetadataSettings,
|
fetchWorkspaceMetadata,
|
||||||
updateWorkspaceMetadataSettings
|
updateWorkspaceMetadata,
|
||||||
} from '$lib/workspace/settings/profile-api';
|
} from '$lib/workspace/settings/profile-api';
|
||||||
import type { Diagnostic } from '$lib/workspace/settings/model';
|
|
||||||
import type { WorkspaceMetadataSettingsResponse } from '$lib/workspace/settings/profile-types';
|
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetchWorkspaceMetadataSettings(workspaceId);
|
const response = await fetchWorkspaceMetadata(workspaceId);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
workspaceMetadata = response;
|
workspaceMetadata = response;
|
||||||
displayNameDraft = response.display_name;
|
displayNameDraft = response.display_name;
|
||||||
@@ -53,7 +55,7 @@
|
|||||||
submitting = true;
|
submitting = true;
|
||||||
message = null;
|
message = null;
|
||||||
try {
|
try {
|
||||||
const response = await updateWorkspaceMetadataSettings(workspaceId, {
|
const response = await updateWorkspaceMetadata(workspaceId, {
|
||||||
display_name: displayNameDraft,
|
display_name: displayNameDraft,
|
||||||
revision: workspaceMetadata.revision
|
revision: workspaceMetadata.revision
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,45 +2,194 @@ declare const Deno: {
|
|||||||
test(name: string, fn: () => void | Promise<void>): void;
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
import { createWorkspaceProfileApi } from "../src/lib/workspace/settings/profile-api.ts";
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Deno.test("workspace profile API delegates metadata calls to current route contract", async () => {
|
function assertThrows<T extends Error>(
|
||||||
|
fn: () => unknown,
|
||||||
|
errorType: abstract new (...args: never[]) => T,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof errorType) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`expected ${errorType.name} to be thrown`);
|
||||||
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchProfileSettings,
|
||||||
|
fetchWorkspaceMetadata,
|
||||||
|
parseProfileSettingsResponse,
|
||||||
|
parseWorkspaceMetadataSettingsResponse,
|
||||||
|
ProfileApiError,
|
||||||
|
updateWorkspaceMetadata,
|
||||||
|
} from "../src/lib/workspace/settings/profile-api.ts";
|
||||||
|
|
||||||
|
const diagnostic = {
|
||||||
|
code: "ok",
|
||||||
|
severity: "info",
|
||||||
|
message: "ready",
|
||||||
|
};
|
||||||
|
|
||||||
|
function profileSettingsFixture(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
workspace_id: "workspace 1",
|
||||||
|
registry_revision: "config-source:7:tree:projection",
|
||||||
|
config_revision: 7,
|
||||||
|
tree_digest: "tree",
|
||||||
|
projection_digest: "projection",
|
||||||
|
default_profile: "workspace:coder",
|
||||||
|
profiles: [{
|
||||||
|
profile_id: "workspace:coder",
|
||||||
|
selector: "workspace:coder",
|
||||||
|
label: "Coder",
|
||||||
|
source_kind: "project",
|
||||||
|
profile_source_id: "profile-source-1",
|
||||||
|
description: null,
|
||||||
|
editable: true,
|
||||||
|
is_default: true,
|
||||||
|
diagnostics: [],
|
||||||
|
}],
|
||||||
|
sources: [{
|
||||||
|
profile_source_id: "profile-source-1",
|
||||||
|
display_path: "profiles/coder.dcdl",
|
||||||
|
kind: "profile",
|
||||||
|
content_type: "text/x-decodal",
|
||||||
|
content_digest: "sha256:source",
|
||||||
|
provenance: "project_profile_source_tree",
|
||||||
|
editable: false,
|
||||||
|
revision: "config-source:7",
|
||||||
|
size_bytes: 128,
|
||||||
|
diagnostics: [],
|
||||||
|
}],
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("profile settings requests use scoped API and strictly validate responses", async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const requests: Array<{ input: string; init?: RequestInit }> = [];
|
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
globalThis.fetch = (input: string | URL | Request, init?: RequestInit) => {
|
||||||
requests.push({ input: String(input), init });
|
requests.push({ url: String(input), init });
|
||||||
return Promise.resolve(Response.json({
|
return Promise.resolve(Response.json(profileSettingsFixture()));
|
||||||
workspace_id: "workspace-a",
|
};
|
||||||
display_name: "Alpha",
|
|
||||||
revision: "revision-2",
|
|
||||||
}));
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = createWorkspaceProfileApi();
|
const response = await fetchProfileSettings("workspace 1");
|
||||||
await api.getMetadata("workspace-a");
|
assertEquals(response.config_revision, 7);
|
||||||
await api.updateMetadata("workspace-a", "Alpha updated", "revision-1");
|
assertEquals(response.sources[0].provenance, "project_profile_source_tree");
|
||||||
|
assertEquals(requests.length, 1);
|
||||||
|
assertEquals(requests[0].url, "/api/w/workspace%201/settings/profiles");
|
||||||
|
assertEquals(requests[0].init, undefined);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (requests.length !== 2) throw new Error("expected two metadata requests");
|
Deno.test("workspace metadata requests use generated DTO shapes", async () => {
|
||||||
if (
|
const originalFetch = globalThis.fetch;
|
||||||
requests.some((request) => request.input.includes("/settings/metadata"))
|
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
) {
|
const workspace = {
|
||||||
throw new Error("obsolete metadata endpoint was used");
|
workspace_id: "workspace 1",
|
||||||
}
|
display_name: "Workspace",
|
||||||
if (
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
requests.some((request) => !request.input.endsWith("/settings/workspace"))
|
revision: "sha256:metadata",
|
||||||
) {
|
source: "workspace-config",
|
||||||
throw new Error("current Workspace settings endpoint was not used");
|
diagnostics: [diagnostic],
|
||||||
}
|
};
|
||||||
const updateBody = JSON.parse(String(requests[1].init?.body));
|
globalThis.fetch = (input: string | URL | Request, init?: RequestInit) => {
|
||||||
if (
|
requests.push({ url: String(input), init });
|
||||||
updateBody.display_name !== "Alpha updated" ||
|
return Promise.resolve(
|
||||||
updateBody.revision !== "revision-1" ||
|
Response.json(
|
||||||
"expected_revision" in updateBody
|
init?.method === "PUT" ? { workspace, diagnostics: [] } : workspace,
|
||||||
) {
|
),
|
||||||
throw new Error(`unexpected update payload: ${JSON.stringify(updateBody)}`);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertEquals(
|
||||||
|
(await fetchWorkspaceMetadata("workspace 1")).revision,
|
||||||
|
"sha256:metadata",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
(await updateWorkspaceMetadata("workspace 1", {
|
||||||
|
display_name: "Renamed",
|
||||||
|
revision: "sha256:metadata",
|
||||||
|
})).workspace.workspace_id,
|
||||||
|
"workspace 1",
|
||||||
|
);
|
||||||
|
assertEquals(requests.map((request) => request.url), [
|
||||||
|
"/api/w/workspace%201/settings/workspace",
|
||||||
|
"/api/w/workspace%201/settings/workspace",
|
||||||
|
]);
|
||||||
|
assertEquals(requests[1].init?.method, "PUT");
|
||||||
|
assertEquals(
|
||||||
|
requests[1].init?.body,
|
||||||
|
JSON.stringify({ display_name: "Renamed", revision: "sha256:metadata" }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid provenance fields", () => {
|
||||||
|
const missing = profileSettingsFixture();
|
||||||
|
delete missing.profiles;
|
||||||
|
assertThrows(
|
||||||
|
() => parseProfileSettingsResponse(missing),
|
||||||
|
ProfileApiError,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mistyped = profileSettingsFixture();
|
||||||
|
mistyped.config_revision = "7";
|
||||||
|
assertThrows(
|
||||||
|
() => parseProfileSettingsResponse(mistyped),
|
||||||
|
ProfileApiError,
|
||||||
|
);
|
||||||
|
|
||||||
|
const stale = profileSettingsFixture();
|
||||||
|
stale.legacy_profile_directory = ".yoi/profiles";
|
||||||
|
assertThrows(
|
||||||
|
() => parseProfileSettingsResponse(stale),
|
||||||
|
ProfileApiError,
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidProvenance = profileSettingsFixture();
|
||||||
|
const sources = invalidProvenance.sources as Array<Record<string, unknown>>;
|
||||||
|
sources[0].provenance = "filesystem";
|
||||||
|
assertThrows(
|
||||||
|
() => parseProfileSettingsResponse(invalidProvenance),
|
||||||
|
ProfileApiError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("workspace metadata parser rejects incomplete or stale response fields", () => {
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseWorkspaceMetadataSettingsResponse({
|
||||||
|
workspace_id: "workspace-test",
|
||||||
|
display_name: "Workspace",
|
||||||
|
}),
|
||||||
|
ProfileApiError,
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseWorkspaceMetadataSettingsResponse({
|
||||||
|
workspace_id: "workspace-test",
|
||||||
|
display_name: "Workspace",
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
revision: "sha256:metadata",
|
||||||
|
source: "workspace-config",
|
||||||
|
diagnostics: [],
|
||||||
|
workspace_path: "/legacy/path",
|
||||||
|
}),
|
||||||
|
ProfileApiError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user