fix: restore workspace routing contracts

This commit is contained in:
2026-08-21 04:36:40 +09:00
parent 8e4f35eb3f
commit da2296bc9c
3 changed files with 114 additions and 17 deletions
+56 -2
View File
@@ -923,6 +923,7 @@ fn is_server_global_forward(path: &str) -> bool {
|| path.starts_with("/api/auth/") || path.starts_with("/api/auth/")
|| path == "/health" || path == "/health"
|| path == "/" || path == "/"
|| path.starts_with("/_app/")
|| path.starts_with("/assets/") || path.starts_with("/assets/")
} }
@@ -12502,7 +12503,7 @@ async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Re
return StatusCode::NOT_FOUND.into_response(); return StatusCode::NOT_FOUND.into_response();
}; };
match read_static_or_index(static_root, uri.path()).await { match read_static_or_index(static_root, scoped_workspace_static_path(uri.path())).await {
Ok(StaticAsset { Ok(StaticAsset {
bytes, bytes,
content_type, content_type,
@@ -12553,6 +12554,16 @@ struct StaticAsset {
content_type: &'static str, content_type: &'static str,
} }
fn scoped_workspace_static_path(path: &str) -> &str {
let Some(scoped) = path.strip_prefix("/w/") else {
return path;
};
match scoped.find('/') {
Some(index) => &scoped[index..],
None => "/",
}
}
async fn read_static_or_index(root: &Path, request_path: &str) -> Result<StaticAsset> { async fn read_static_or_index(root: &Path, request_path: &str) -> Result<StaticAsset> {
let candidate = safe_static_candidate(root, request_path)?; let candidate = safe_static_candidate(root, request_path)?;
let file = if tokio::fs::metadata(&candidate) let file = if tokio::fs::metadata(&candidate)
@@ -14427,7 +14438,20 @@ mod tests {
let repository_b = dir.path().join("repository-b"); let repository_b = dir.path().join("repository-b");
std::fs::create_dir_all(repository_a.join(".git")).unwrap(); std::fs::create_dir_all(repository_a.join(".git")).unwrap();
std::fs::create_dir_all(repository_b.join(".git")).unwrap(); std::fs::create_dir_all(repository_b.join(".git")).unwrap();
let template = test_server_config(dir.path()); let static_dir = dir.path().join("static");
std::fs::create_dir_all(static_dir.join("_app/immutable/entry")).unwrap();
std::fs::write(
static_dir.join("index.html"),
"<main>Workspace chooser</main>",
)
.unwrap();
std::fs::write(
static_dir.join("_app/immutable/entry/start.js"),
"console.log('workspace app');",
)
.unwrap();
let mut template = test_server_config(dir.path());
template.static_assets_dir = Some(static_dir);
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
let catalog = WorkspaceCatalogService::new(store.clone()); let catalog = WorkspaceCatalogService::new(store.clone());
let workspace_a = catalog let workspace_a = catalog
@@ -14470,6 +14494,36 @@ mod tests {
assert_eq!(b["workspace_id"], workspace_b.workspace.workspace_id); assert_eq!(b["workspace_id"], workspace_b.workspace.workspace_id);
assert_eq!(b["display_name"], "Workspace B"); assert_eq!(b["display_name"], "Workspace B");
for asset_uri in [
"/_app/immutable/entry/start.js".to_string(),
format!(
"/w/{}/_app/immutable/entry/start.js",
workspace_a.workspace.workspace_id
),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(asset_uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
String::from_utf8(
to_bytes(response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec(),
)
.unwrap(),
"console.log('workspace app');"
);
}
let handle = missing_resource_handle(); let handle = missing_resource_handle();
let resource_response = app let resource_response = app
.clone() .clone()
@@ -14,7 +14,10 @@ export type WorkspaceProfileApi = {
getProfiles(workspaceId: string): Promise<ProfileSettingsResponse>; getProfiles(workspaceId: string): Promise<ProfileSettingsResponse>;
}; };
async function requestJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> { async function requestJson<T>(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<T> {
const response = await fetch(input, init); const response = await fetch(input, init);
if (!response.ok) { if (!response.ok) {
throw new Error(`request failed: ${response.status}`); throw new Error(`request failed: ${response.status}`);
@@ -44,7 +47,9 @@ export async function updateWorkspaceMetadataSettings(
); );
} }
export async function fetchProfileSettings(workspaceId: string): Promise<ProfileSettingsResponse> { export async function fetchProfileSettings(
workspaceId: string,
): Promise<ProfileSettingsResponse> {
return await requestJson<ProfileSettingsResponse>( return await requestJson<ProfileSettingsResponse>(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`,
); );
@@ -52,20 +57,12 @@ export async function fetchProfileSettings(workspaceId: string): Promise<Profile
export function createWorkspaceProfileApi(): WorkspaceProfileApi { export function createWorkspaceProfileApi(): WorkspaceProfileApi {
return { return {
async getMetadata(workspaceId) { getMetadata: fetchWorkspaceMetadataSettings,
return await requestJson<WorkspaceMetadataSettingsResponse>(
`/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`,
);
},
async updateMetadata(workspaceId, displayName, expectedRevision) { async updateMetadata(workspaceId, displayName, expectedRevision) {
return await requestJson<WorkspaceMetadataMutationResponse>( return await updateWorkspaceMetadataSettings(workspaceId, {
`/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`, display_name: displayName,
{ revision: expectedRevision,
method: "PUT", });
headers: { "content-type": "application/json" },
body: JSON.stringify({ display_name: displayName, expected_revision: expectedRevision }),
},
);
}, },
getProfiles: fetchProfileSettings, getProfiles: fetchProfileSettings,
}; };
+46
View File
@@ -0,0 +1,46 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import { createWorkspaceProfileApi } from "../src/lib/workspace/settings/profile-api.ts";
Deno.test("workspace profile API delegates metadata calls to current route contract", async () => {
const originalFetch = globalThis.fetch;
const requests: Array<{ input: string; init?: RequestInit }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ input: String(input), init });
return Promise.resolve(Response.json({
workspace_id: "workspace-a",
display_name: "Alpha",
revision: "revision-2",
}));
}) as typeof fetch;
try {
const api = createWorkspaceProfileApi();
await api.getMetadata("workspace-a");
await api.updateMetadata("workspace-a", "Alpha updated", "revision-1");
} finally {
globalThis.fetch = originalFetch;
}
if (requests.length !== 2) throw new Error("expected two metadata requests");
if (
requests.some((request) => request.input.includes("/settings/metadata"))
) {
throw new Error("obsolete metadata endpoint was used");
}
if (
requests.some((request) => !request.input.endsWith("/settings/workspace"))
) {
throw new Error("current Workspace settings endpoint was not used");
}
const updateBody = JSON.parse(String(requests[1].init?.body));
if (
updateBody.display_name !== "Alpha updated" ||
updateBody.revision !== "revision-1" ||
"expected_revision" in updateBody
) {
throw new Error(`unexpected update payload: ${JSON.stringify(updateBody)}`);
}
});