From aa493b932615252ed678ed41377b84c78d58ebca Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 9 Jun 2026 18:27:50 +0200 Subject: [PATCH] fix(admin-api): accept slug or UUID on per-app endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Queues tab at /admin/apps/default/queues was returning "Cannot parse `app_id` with value `default`: UUID parsing failed". 27 admin endpoints across 6 files used strict `Path` instead of the canonical `Path` + `resolve_app()` pattern from app_repo.rs:34. Working endpoints (apps, app_members, users_admin) all use the lenient pattern; this commit brings the remaining 6 files into line: - queues_api.rs — 2 handlers - files_api.rs — 2 handlers - secrets_api.rs — 3 handlers - topics_api.rs — 4 handlers - dead_letters_api.rs — 5 handlers - triggers_api.rs — 10 handlers The handler bodies (authz, repo calls) are unchanged; only the path extractor and the per-file `ensure_app_exists` helper (now renamed `resolve_app`) move. Lib tests updated to pass `.to_string()` at the call site (Path now takes String, not AppId). email_inbound_api.rs deliberately stays strict-UUID — it's a public webhook receiver consumed by external providers, not by the slug-based dashboard. Adds Playwright spec `dashboard/tests/e2e/navigation/tabs.spec.ts` covering every per-app tab (queues, files, dead-letters, users, invitations, plus the main page hosting triggers/secrets/topics) with a negative assertion against the "Cannot parse" error text plus a focused regression test for the original queues report. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/manager-core/src/dead_letters_api.rs | 28 ++--- crates/manager-core/src/files_api.rs | 16 +-- crates/manager-core/src/queues_api.rs | 16 +-- crates/manager-core/src/secrets_api.rs | 20 ++-- crates/manager-core/src/topics_api.rs | 36 +++---- crates/manager-core/src/triggers_api.rs | 111 ++++++++++---------- dashboard/tests/e2e/navigation/tabs.spec.ts | 80 ++++++++++++++ 7 files changed, 192 insertions(+), 115 deletions(-) create mode 100644 dashboard/tests/e2e/navigation/tabs.spec.ts diff --git a/crates/manager-core/src/dead_letters_api.rs b/crates/manager-core/src/dead_letters_api.rs index b78a04f..9db01ee 100644 --- a/crates/manager-core/src/dead_letters_api.rs +++ b/crates/manager-core/src/dead_letters_api.rs @@ -114,10 +114,10 @@ impl From for DeadLetterDto { async fn list( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Query(q): Query, ) -> Result, DeadLettersApiError> { - ensure_app(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -136,9 +136,9 @@ async fn list( async fn count( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, ) -> Result, DeadLettersApiError> { - ensure_app(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -152,9 +152,9 @@ async fn count( async fn detail( State(s): State, Extension(principal): Extension, - Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>, + Path((id_or_slug, dl_id)): Path<(String, DeadLetterId)>, ) -> Result, DeadLettersApiError> { - ensure_app(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -175,9 +175,9 @@ async fn detail( async fn replay( State(s): State, Extension(principal): Extension, - Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>, + Path((id_or_slug, dl_id)): Path<(String, DeadLetterId)>, ) -> Result { - ensure_app(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; // Authz handled inside the service via SdkCallCx. let cx = admin_cx(app_id, &principal); s.service @@ -190,10 +190,10 @@ async fn replay( async fn resolve( State(s): State, Extension(principal): Extension, - Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>, + Path((id_or_slug, dl_id)): Path<(String, DeadLetterId)>, Json(body): Json, ) -> Result { - ensure_app(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; let cx = admin_cx(app_id, &principal); s.service .resolve(&cx, dl_id, &body.reason) @@ -221,12 +221,12 @@ fn admin_cx(app_id: AppId, principal: &Principal) -> SdkCallCx { } } -async fn ensure_app(apps: &dyn AppRepository, app_id: AppId) -> Result<(), DeadLettersApiError> { - apps.get_by_id(app_id) +async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| DeadLettersApiError::Backend(e.to_string()))? - .ok_or_else(|| DeadLettersApiError::AppNotFound(app_id.to_string()))?; - Ok(()) + .map(|l| l.app.id) + .ok_or_else(|| DeadLettersApiError::AppNotFound(ident.to_string())) } fn map_service_err(e: picloud_shared::DeadLetterError) -> DeadLettersApiError { diff --git a/crates/manager-core/src/files_api.rs b/crates/manager-core/src/files_api.rs index af8120c..ff96c2b 100644 --- a/crates/manager-core/src/files_api.rs +++ b/crates/manager-core/src/files_api.rs @@ -76,10 +76,10 @@ struct ListFilesResponse { async fn list_files( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Query(q): Query, ) -> Result, FilesApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -123,9 +123,9 @@ async fn list_files( async fn delete_file( State(s): State, Extension(principal): Extension, - Path((app_id, collection, file_id)): Path<(AppId, String, String)>, + Path((id_or_slug, collection, file_id)): Path<(String, String, String)>, ) -> Result { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -139,12 +139,12 @@ async fn delete_file( Ok(StatusCode::NO_CONTENT) } -async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), FilesApiError> { - apps.get_by_id(app_id) +async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| FilesApiError::Backend(e.to_string()))? - .ok_or(FilesApiError::AppNotFound)?; - Ok(()) + .map(|l| l.app.id) + .ok_or(FilesApiError::AppNotFound) } #[derive(Debug, thiserror::Error)] diff --git a/crates/manager-core/src/queues_api.rs b/crates/manager-core/src/queues_api.rs index 53249e1..8dc670b 100644 --- a/crates/manager-core/src/queues_api.rs +++ b/crates/manager-core/src/queues_api.rs @@ -92,9 +92,9 @@ pub struct QueueConsumer { async fn list_queues( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, ) -> Result>, QueuesApiError> { - ensure_app(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require_log_read(&*s.authz, &principal, app_id).await?; let rows = s .queues @@ -116,9 +116,9 @@ async fn list_queues( async fn get_queue( State(s): State, Extension(principal): Extension, - Path((app_id, queue_name)): Path<(AppId, String)>, + Path((id_or_slug, queue_name)): Path<(String, String)>, ) -> Result, QueuesApiError> { - ensure_app(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require_log_read(&*s.authz, &principal, app_id).await?; let total = s @@ -185,12 +185,12 @@ async fn get_queue( })) } -async fn ensure_app(apps: &dyn AppRepository, app_id: AppId) -> Result<(), QueuesApiError> { - apps.get_by_id(app_id) +async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| QueuesApiError::Repo(e.to_string()))? - .ok_or(QueuesApiError::AppNotFound)?; - Ok(()) + .map(|l| l.app.id) + .ok_or(QueuesApiError::AppNotFound) } async fn require_log_read( diff --git a/crates/manager-core/src/secrets_api.rs b/crates/manager-core/src/secrets_api.rs index bb8ba29..e4a50b7 100644 --- a/crates/manager-core/src/secrets_api.rs +++ b/crates/manager-core/src/secrets_api.rs @@ -70,10 +70,10 @@ struct ListSecretsResponse { async fn list_secrets( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Query(q): Query, ) -> Result, SecretsApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -108,10 +108,10 @@ pub struct SetSecretRequest { async fn set_secret( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Json(input): Json, ) -> Result { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -127,9 +127,9 @@ async fn set_secret( async fn delete_secret( State(s): State, Extension(principal): Extension, - Path((app_id, name)): Path<(AppId, String)>, + Path((id_or_slug, name)): Path<(String, String)>, ) -> Result { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -142,12 +142,12 @@ async fn delete_secret( Ok(StatusCode::NO_CONTENT) } -async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), SecretsApiError> { - apps.get_by_id(app_id) +async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| SecretsApiError::Backend(e.to_string()))? - .ok_or(SecretsApiError::AppNotFound)?; - Ok(()) + .map(|l| l.app.id) + .ok_or(SecretsApiError::AppNotFound) } #[derive(Debug, thiserror::Error)] diff --git a/crates/manager-core/src/topics_api.rs b/crates/manager-core/src/topics_api.rs index 2d5bc53..39c5086 100644 --- a/crates/manager-core/src/topics_api.rs +++ b/crates/manager-core/src/topics_api.rs @@ -92,10 +92,10 @@ fn validate_topic_name(name: &str) -> Result<(), TopicsApiError> { async fn create_topic( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TopicsApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -123,9 +123,9 @@ struct ListTopicsResponse { async fn list_topics( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, ) -> Result, TopicsApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require(s.authz.as_ref(), &principal, Capability::AppRead(app_id)).await?; let topics = s.topics.list(app_id).await?; Ok(Json(ListTopicsResponse { topics })) @@ -134,10 +134,10 @@ async fn list_topics( async fn update_topic( State(s): State, Extension(principal): Extension, - Path((app_id, name)): Path<(AppId, String)>, + Path((id_or_slug, name)): Path<(String, String)>, Json(input): Json, ) -> Result, TopicsApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -155,9 +155,9 @@ async fn update_topic( async fn delete_topic( State(s): State, Extension(principal): Extension, - Path((app_id, name)): Path<(AppId, String)>, + Path((id_or_slug, name)): Path<(String, String)>, ) -> Result { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -172,12 +172,12 @@ async fn delete_topic( Ok(StatusCode::NO_CONTENT) } -async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), TopicsApiError> { - apps.get_by_id(app_id) +async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| TopicsApiError::Backend(e.to_string()))? - .ok_or(TopicsApiError::AppNotFound)?; - Ok(()) + .map(|l| l.app.id) + .ok_or(TopicsApiError::AppNotFound) } #[derive(Debug, thiserror::Error)] @@ -501,7 +501,7 @@ mod tests { let (status, Json(topic)) = create_topic( State(s), Extension(member()), - Path(app), + Path(app.to_string()), Json(CreateTopicRequest { name: "chat".into(), external_subscribable: false, @@ -527,7 +527,7 @@ mod tests { let err = update_topic( State(s), Extension(member()), - Path((app, "chat".to_string())), + Path((app.to_string(), "chat".to_string())), Json(UpdateTopicRequest { external_subscribable: Some(true), auth_mode: None, @@ -551,7 +551,7 @@ mod tests { let Json(updated) = update_topic( State(s), Extension(member()), - Path((app, "chat".to_string())), + Path((app.to_string(), "chat".to_string())), Json(UpdateTopicRequest { external_subscribable: Some(true), auth_mode: Some(TopicAuthMode::Token), @@ -574,7 +574,7 @@ mod tests { let status = delete_topic( State(s), Extension(member()), - Path((app, "chat".to_string())), + Path((app.to_string(), "chat".to_string())), ) .await .unwrap(); @@ -596,7 +596,7 @@ mod tests { let err = create_topic( State(s), Extension(member()), - Path(app_b), + Path(app_b.to_string()), Json(CreateTopicRequest { name: "chat".into(), external_subscribable: true, @@ -615,7 +615,7 @@ mod tests { let err = create_topic( State(s), Extension(member()), - Path(app), + Path(app.to_string()), Json(CreateTopicRequest { name: "user.*".into(), external_subscribable: true, diff --git a/crates/manager-core/src/triggers_api.rs b/crates/manager-core/src/triggers_api.rs index a56bd5b..05c860f 100644 --- a/crates/manager-core/src/triggers_api.rs +++ b/crates/manager-core/src/triggers_api.rs @@ -195,9 +195,9 @@ pub struct TriggerListResponse { async fn list_triggers( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, ) -> Result, TriggersApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -249,10 +249,10 @@ fn map_script_repo_err(e: ScriptRepositoryError) -> TriggersApiError { async fn create_kv_trigger( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -286,10 +286,10 @@ async fn create_kv_trigger( async fn create_docs_trigger( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -323,10 +323,10 @@ async fn create_docs_trigger( async fn create_cron_trigger( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -400,10 +400,10 @@ pub struct CreateQueueTriggerRequest { async fn create_pubsub_trigger( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -435,10 +435,10 @@ async fn create_pubsub_trigger( async fn create_files_trigger( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -472,10 +472,10 @@ async fn create_files_trigger( async fn create_dl_trigger( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -506,10 +506,10 @@ struct CreateEmailTriggerRequest { async fn create_email_trigger( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -551,10 +551,10 @@ async fn create_email_trigger( async fn create_queue_trigger( State(s): State, Extension(principal): Extension, - Path(app_id): Path, + Path(id_or_slug): Path, Json(input): Json, ) -> Result<(StatusCode, Json), TriggersApiError> { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, @@ -590,9 +590,9 @@ async fn create_queue_trigger( async fn delete_trigger( State(s): State, Extension(principal): Extension, - Path((app_id, trigger_id)): Path<(AppId, TriggerId)>, + Path((id_or_slug, trigger_id)): Path<(String, TriggerId)>, ) -> Result { - ensure_app_exists(&*s.apps, app_id).await?; + let app_id = resolve_app(&*s.apps, &id_or_slug).await?; // Load the trigger so we can confirm it belongs to the right // app; this prevents a caller from deleting a trigger by id alone // when their capability is bound to a different app. @@ -616,15 +616,12 @@ async fn delete_trigger( Ok(StatusCode::NO_CONTENT) } -async fn ensure_app_exists( - apps: &dyn AppRepository, - app_id: AppId, -) -> Result<(), TriggersApiError> { - apps.get_by_id(app_id) +async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { + crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| TriggersApiError::Backend(e.to_string()))? - .ok_or_else(|| TriggersApiError::AppNotFound(app_id.to_string()))?; - Ok(()) + .map(|l| l.app.id) + .ok_or_else(|| TriggersApiError::AppNotFound(ident.to_string())) } // ---------------------------------------------------------------------------- @@ -1347,7 +1344,7 @@ mod tests { let res = create_kv_trigger( State(state), Extension(member_principal()), - Path(AppId::new()), // a different (non-existent) app + Path(AppId::new().to_string()), // a different (non-existent) app Json(CreateKvTriggerRequest { script_id: ScriptId::new(), collection_glob: "*".into(), @@ -1370,7 +1367,7 @@ mod tests { let res = create_kv_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateKvTriggerRequest { script_id: ScriptId::new(), collection_glob: "*".into(), @@ -1397,7 +1394,7 @@ mod tests { let (status, Json(trigger)) = create_kv_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateKvTriggerRequest { script_id, collection_glob: "widgets".into(), @@ -1422,7 +1419,7 @@ mod tests { let res = create_kv_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateKvTriggerRequest { script_id: ScriptId::new(), collection_glob: " ".into(), @@ -1446,7 +1443,7 @@ mod tests { let (status, Json(trigger)) = create_docs_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateDocsTriggerRequest { script_id, collection_glob: "users".into(), @@ -1483,7 +1480,7 @@ mod tests { let res = create_docs_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateDocsTriggerRequest { script_id: ScriptId::new(), collection_glob: " ".into(), @@ -1506,7 +1503,7 @@ mod tests { let res = create_docs_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateDocsTriggerRequest { script_id: ScriptId::new(), collection_glob: "users".into(), @@ -1589,7 +1586,7 @@ mod tests { let res = delete_trigger( State(state), Extension(member_principal()), - Path((app_b, trigger.id)), + Path((app_b.to_string(), trigger.id)), ) .await; let err = res.expect_err("cross-app delete should 404"); @@ -1615,7 +1612,7 @@ mod tests { let res = create_kv_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateKvTriggerRequest { script_id, collection_glob: "widgets".into(), @@ -1653,7 +1650,7 @@ mod tests { let res = create_docs_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateDocsTriggerRequest { script_id, collection_glob: "users".into(), @@ -1688,7 +1685,7 @@ mod tests { let res = create_dl_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateDeadLetterTriggerRequest { script_id, source_filter: None, @@ -1713,7 +1710,7 @@ mod tests { let res = create_kv_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateKvTriggerRequest { script_id: ScriptId::new(), collection_glob: "widgets".into(), @@ -1754,7 +1751,7 @@ mod tests { let res = create_kv_trigger( State(state), Extension(member_principal()), - Path(app_a), + Path(app_a.to_string()), Json(CreateKvTriggerRequest { script_id, collection_glob: "widgets".into(), @@ -1801,7 +1798,7 @@ mod tests { let (status, Json(trigger)) = create_cron_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(cron_req( script_id, "0 0 9 * * MON-FRI", @@ -1837,7 +1834,7 @@ mod tests { let res = create_cron_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), // 5-field expression — not the 6-field format we accept. Json(cron_req(script_id, "* * * * *", "UTC")), ) @@ -1858,7 +1855,7 @@ mod tests { let res = create_cron_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(cron_req(script_id, "0 * * * * *", "Mars/Phobos")), ) .await; @@ -1885,7 +1882,7 @@ mod tests { let res = create_cron_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(cron_req(script_id, "0 * * * * *", "UTC")), ) .await; @@ -1915,7 +1912,7 @@ mod tests { let res = create_cron_trigger( State(state), Extension(member_principal()), - Path(app_a), + Path(app_a.to_string()), Json(cron_req(script_id, "0 * * * * *", "UTC")), ) .await; @@ -1934,7 +1931,7 @@ mod tests { let res = create_cron_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(cron_req(ScriptId::new(), "0 * * * * *", "UTC")), ) .await; @@ -1950,7 +1947,7 @@ mod tests { let (status, _) = create_kv_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(CreateKvTriggerRequest { script_id, collection_glob: "widgets".into(), @@ -1990,7 +1987,7 @@ mod tests { let (status, Json(trigger)) = create_files_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(files_req(script_id, "avatars")), ) .await @@ -2019,7 +2016,7 @@ mod tests { let res = create_files_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(files_req(ScriptId::new(), " ")), ) .await; @@ -2044,7 +2041,7 @@ mod tests { let res = create_files_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(files_req(script_id, "avatars")), ) .await; @@ -2071,7 +2068,7 @@ mod tests { let res = create_files_trigger( State(state), Extension(member_principal()), - Path(app_a), + Path(app_a.to_string()), Json(files_req(script_id, "avatars")), ) .await; @@ -2089,7 +2086,7 @@ mod tests { let res = create_files_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(files_req(ScriptId::new(), "avatars")), ) .await; @@ -2118,7 +2115,7 @@ mod tests { let (status, Json(trigger)) = create_pubsub_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(pubsub_req(script_id, "user.*")), ) .await @@ -2139,7 +2136,7 @@ mod tests { let res = create_pubsub_trigger( State(state.clone()), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(pubsub_req(script_id, bad)), ) .await; @@ -2169,7 +2166,7 @@ mod tests { let res = create_pubsub_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(pubsub_req(script_id, "user.*")), ) .await; @@ -2196,7 +2193,7 @@ mod tests { let res = create_pubsub_trigger( State(state), Extension(member_principal()), - Path(app_a), + Path(app_a.to_string()), Json(pubsub_req(script_id, "user.*")), ) .await; @@ -2214,7 +2211,7 @@ mod tests { let res = create_pubsub_trigger( State(state), Extension(member_principal()), - Path(app_id), + Path(app_id.to_string()), Json(pubsub_req(ScriptId::new(), "user.*")), ) .await; diff --git a/dashboard/tests/e2e/navigation/tabs.spec.ts b/dashboard/tests/e2e/navigation/tabs.spec.ts new file mode 100644 index 0000000..11e108c --- /dev/null +++ b/dashboard/tests/e2e/navigation/tabs.spec.ts @@ -0,0 +1,80 @@ +import { expect } from '@playwright/test'; +import { test } from '../fixtures/ids'; +import { CleanupRegistry } from '../fixtures/cleanup'; +import { adminApi } from '../fixtures/api'; + +// Regression coverage for the slug-vs-UUID bug: every per-app tab +// (`/admin/apps/[slug]/...`) must accept the URL slug and not surface +// the "Cannot parse `app_id` with value ``" error. The original +// report was the Queues tab, but the bug surface spans six backend +// files — see AUDIT.md's Phase 1 plan in chore/audit branch. +// +// Each tab is hit in isolation against a freshly-created app so the +// test doesn't depend on the dev-seeded `default` app being present. + +const PARSE_ERROR = /Cannot parse|UUID parsing failed/i; + +const cleanup = new CleanupRegistry(); +test.afterEach(async () => { + await cleanup.run(); +}); + +test.describe('per-app tab navigation accepts slug URLs', () => { + test('every tab loads without an app_id parse error', async ({ page, uniqueSlug }) => { + const slug = uniqueSlug('nav'); + const api = await adminApi(); + try { + const res = await api.post('/api/v1/admin/apps', { + data: { slug, name: slug } + }); + expect(res.ok()).toBe(true); + } finally { + await api.dispose(); + } + cleanup.app(slug); + + const tabs = [ + { path: `/admin/apps/${slug}`, label: 'main (triggers/secrets/topics)' }, + { path: `/admin/apps/${slug}/queues`, label: 'queues' }, + { path: `/admin/apps/${slug}/files`, label: 'files' }, + { path: `/admin/apps/${slug}/dead-letters`, label: 'dead-letters' }, + { path: `/admin/apps/${slug}/users`, label: 'app users' }, + { path: `/admin/apps/${slug}/users/invitations`, label: 'app invitations' } + ]; + + for (const tab of tabs) { + await page.goto(tab.path); + // Network must settle so any error banner from the failed + // API call has rendered before we assert its absence. + await page.waitForLoadState('networkidle'); + await expect( + page.getByText(PARSE_ERROR), + `expected no app_id parse error on ${tab.label} (${tab.path})` + ).toHaveCount(0); + } + }); + + test('queues tab specifically — the original bug report', async ({ page, uniqueSlug }) => { + // The bug surfaced on the queues tab. Pin a focused test so a + // regression on just that page is easy to spot in CI output. + const slug = uniqueSlug('queues'); + const api = await adminApi(); + try { + const res = await api.post('/api/v1/admin/apps', { + data: { slug, name: slug } + }); + expect(res.ok()).toBe(true); + } finally { + await api.dispose(); + } + cleanup.app(slug); + + await page.goto(`/admin/apps/${slug}/queues`); + await page.waitForLoadState('networkidle'); + await expect(page.getByText(PARSE_ERROR)).toHaveCount(0); + // Positive assertion: the empty-state copy renders. If the API + // call fails, this string never appears because the page bails + // into the error branch. + await expect(page.getByText('No queues in this app yet', { exact: false })).toBeVisible(); + }); +});