fix(admin-api): accept slug or UUID on per-app endpoints

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<AppId>` instead
of the canonical `Path<String>` + `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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-09 18:27:50 +02:00
parent c1e4c3416b
commit aa493b9326
7 changed files with 192 additions and 115 deletions

View File

@@ -114,10 +114,10 @@ impl From<DeadLetterRow> for DeadLetterDto {
async fn list( async fn list(
State(s): State<DeadLettersState>, State(s): State<DeadLettersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Query(q): Query<ListQuery>, Query(q): Query<ListQuery>,
) -> Result<Json<ListResponse>, DeadLettersApiError> { ) -> Result<Json<ListResponse>, DeadLettersApiError> {
ensure_app(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -136,9 +136,9 @@ async fn list(
async fn count( async fn count(
State(s): State<DeadLettersState>, State(s): State<DeadLettersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
) -> Result<Json<CountResponse>, DeadLettersApiError> { ) -> Result<Json<CountResponse>, DeadLettersApiError> {
ensure_app(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -152,9 +152,9 @@ async fn count(
async fn detail( async fn detail(
State(s): State<DeadLettersState>, State(s): State<DeadLettersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>, Path((id_or_slug, dl_id)): Path<(String, DeadLetterId)>,
) -> Result<Json<DeadLetterDto>, DeadLettersApiError> { ) -> Result<Json<DeadLetterDto>, DeadLettersApiError> {
ensure_app(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -175,9 +175,9 @@ async fn detail(
async fn replay( async fn replay(
State(s): State<DeadLettersState>, State(s): State<DeadLettersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>, Path((id_or_slug, dl_id)): Path<(String, DeadLetterId)>,
) -> Result<StatusCode, DeadLettersApiError> { ) -> Result<StatusCode, DeadLettersApiError> {
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. // Authz handled inside the service via SdkCallCx.
let cx = admin_cx(app_id, &principal); let cx = admin_cx(app_id, &principal);
s.service s.service
@@ -190,10 +190,10 @@ async fn replay(
async fn resolve( async fn resolve(
State(s): State<DeadLettersState>, State(s): State<DeadLettersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>, Path((id_or_slug, dl_id)): Path<(String, DeadLetterId)>,
Json(body): Json<ResolveBody>, Json(body): Json<ResolveBody>,
) -> Result<StatusCode, DeadLettersApiError> { ) -> Result<StatusCode, DeadLettersApiError> {
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); let cx = admin_cx(app_id, &principal);
s.service s.service
.resolve(&cx, dl_id, &body.reason) .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> { async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, DeadLettersApiError> {
apps.get_by_id(app_id) crate::app_repo::resolve_app(apps, ident)
.await .await
.map_err(|e| DeadLettersApiError::Backend(e.to_string()))? .map_err(|e| DeadLettersApiError::Backend(e.to_string()))?
.ok_or_else(|| DeadLettersApiError::AppNotFound(app_id.to_string()))?; .map(|l| l.app.id)
Ok(()) .ok_or_else(|| DeadLettersApiError::AppNotFound(ident.to_string()))
} }
fn map_service_err(e: picloud_shared::DeadLetterError) -> DeadLettersApiError { fn map_service_err(e: picloud_shared::DeadLetterError) -> DeadLettersApiError {

View File

@@ -76,10 +76,10 @@ struct ListFilesResponse {
async fn list_files( async fn list_files(
State(s): State<FilesAdminState>, State(s): State<FilesAdminState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Query(q): Query<ListFilesQuery>, Query(q): Query<ListFilesQuery>,
) -> Result<Json<ListFilesResponse>, FilesApiError> { ) -> Result<Json<ListFilesResponse>, FilesApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -123,9 +123,9 @@ async fn list_files(
async fn delete_file( async fn delete_file(
State(s): State<FilesAdminState>, State(s): State<FilesAdminState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path((app_id, collection, file_id)): Path<(AppId, String, String)>, Path((id_or_slug, collection, file_id)): Path<(String, String, String)>,
) -> Result<StatusCode, FilesApiError> { ) -> Result<StatusCode, FilesApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -139,12 +139,12 @@ async fn delete_file(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), FilesApiError> { async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, FilesApiError> {
apps.get_by_id(app_id) crate::app_repo::resolve_app(apps, ident)
.await .await
.map_err(|e| FilesApiError::Backend(e.to_string()))? .map_err(|e| FilesApiError::Backend(e.to_string()))?
.ok_or(FilesApiError::AppNotFound)?; .map(|l| l.app.id)
Ok(()) .ok_or(FilesApiError::AppNotFound)
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]

View File

@@ -92,9 +92,9 @@ pub struct QueueConsumer {
async fn list_queues( async fn list_queues(
State(s): State<QueuesState>, State(s): State<QueuesState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<QueueSummary>>, QueuesApiError> { ) -> Result<Json<Vec<QueueSummary>>, 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?; require_log_read(&*s.authz, &principal, app_id).await?;
let rows = s let rows = s
.queues .queues
@@ -116,9 +116,9 @@ async fn list_queues(
async fn get_queue( async fn get_queue(
State(s): State<QueuesState>, State(s): State<QueuesState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path((app_id, queue_name)): Path<(AppId, String)>, Path((id_or_slug, queue_name)): Path<(String, String)>,
) -> Result<Json<QueueDetail>, QueuesApiError> { ) -> Result<Json<QueueDetail>, 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?; require_log_read(&*s.authz, &principal, app_id).await?;
let total = s let total = s
@@ -185,12 +185,12 @@ async fn get_queue(
})) }))
} }
async fn ensure_app(apps: &dyn AppRepository, app_id: AppId) -> Result<(), QueuesApiError> { async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, QueuesApiError> {
apps.get_by_id(app_id) crate::app_repo::resolve_app(apps, ident)
.await .await
.map_err(|e| QueuesApiError::Repo(e.to_string()))? .map_err(|e| QueuesApiError::Repo(e.to_string()))?
.ok_or(QueuesApiError::AppNotFound)?; .map(|l| l.app.id)
Ok(()) .ok_or(QueuesApiError::AppNotFound)
} }
async fn require_log_read( async fn require_log_read(

View File

@@ -70,10 +70,10 @@ struct ListSecretsResponse {
async fn list_secrets( async fn list_secrets(
State(s): State<SecretsState>, State(s): State<SecretsState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Query(q): Query<ListQuery>, Query(q): Query<ListQuery>,
) -> Result<Json<ListSecretsResponse>, SecretsApiError> { ) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -108,10 +108,10 @@ pub struct SetSecretRequest {
async fn set_secret( async fn set_secret(
State(s): State<SecretsState>, State(s): State<SecretsState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Json(input): Json<SetSecretRequest>, Json(input): Json<SetSecretRequest>,
) -> Result<StatusCode, SecretsApiError> { ) -> Result<StatusCode, SecretsApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -127,9 +127,9 @@ async fn set_secret(
async fn delete_secret( async fn delete_secret(
State(s): State<SecretsState>, State(s): State<SecretsState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path((app_id, name)): Path<(AppId, String)>, Path((id_or_slug, name)): Path<(String, String)>,
) -> Result<StatusCode, SecretsApiError> { ) -> Result<StatusCode, SecretsApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -142,12 +142,12 @@ async fn delete_secret(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), SecretsApiError> { async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, SecretsApiError> {
apps.get_by_id(app_id) crate::app_repo::resolve_app(apps, ident)
.await .await
.map_err(|e| SecretsApiError::Backend(e.to_string()))? .map_err(|e| SecretsApiError::Backend(e.to_string()))?
.ok_or(SecretsApiError::AppNotFound)?; .map(|l| l.app.id)
Ok(()) .ok_or(SecretsApiError::AppNotFound)
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]

View File

@@ -92,10 +92,10 @@ fn validate_topic_name(name: &str) -> Result<(), TopicsApiError> {
async fn create_topic( async fn create_topic(
State(s): State<TopicsState>, State(s): State<TopicsState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Json(input): Json<CreateTopicRequest>, Json(input): Json<CreateTopicRequest>,
) -> Result<(StatusCode, Json<Topic>), TopicsApiError> { ) -> Result<(StatusCode, Json<Topic>), TopicsApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -123,9 +123,9 @@ struct ListTopicsResponse {
async fn list_topics( async fn list_topics(
State(s): State<TopicsState>, State(s): State<TopicsState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
) -> Result<Json<ListTopicsResponse>, TopicsApiError> { ) -> Result<Json<ListTopicsResponse>, 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?; require(s.authz.as_ref(), &principal, Capability::AppRead(app_id)).await?;
let topics = s.topics.list(app_id).await?; let topics = s.topics.list(app_id).await?;
Ok(Json(ListTopicsResponse { topics })) Ok(Json(ListTopicsResponse { topics }))
@@ -134,10 +134,10 @@ async fn list_topics(
async fn update_topic( async fn update_topic(
State(s): State<TopicsState>, State(s): State<TopicsState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path((app_id, name)): Path<(AppId, String)>, Path((id_or_slug, name)): Path<(String, String)>,
Json(input): Json<UpdateTopicRequest>, Json(input): Json<UpdateTopicRequest>,
) -> Result<Json<Topic>, TopicsApiError> { ) -> Result<Json<Topic>, TopicsApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -155,9 +155,9 @@ async fn update_topic(
async fn delete_topic( async fn delete_topic(
State(s): State<TopicsState>, State(s): State<TopicsState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path((app_id, name)): Path<(AppId, String)>, Path((id_or_slug, name)): Path<(String, String)>,
) -> Result<StatusCode, TopicsApiError> { ) -> Result<StatusCode, TopicsApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -172,12 +172,12 @@ async fn delete_topic(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), TopicsApiError> { async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, TopicsApiError> {
apps.get_by_id(app_id) crate::app_repo::resolve_app(apps, ident)
.await .await
.map_err(|e| TopicsApiError::Backend(e.to_string()))? .map_err(|e| TopicsApiError::Backend(e.to_string()))?
.ok_or(TopicsApiError::AppNotFound)?; .map(|l| l.app.id)
Ok(()) .ok_or(TopicsApiError::AppNotFound)
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -501,7 +501,7 @@ mod tests {
let (status, Json(topic)) = create_topic( let (status, Json(topic)) = create_topic(
State(s), State(s),
Extension(member()), Extension(member()),
Path(app), Path(app.to_string()),
Json(CreateTopicRequest { Json(CreateTopicRequest {
name: "chat".into(), name: "chat".into(),
external_subscribable: false, external_subscribable: false,
@@ -527,7 +527,7 @@ mod tests {
let err = update_topic( let err = update_topic(
State(s), State(s),
Extension(member()), Extension(member()),
Path((app, "chat".to_string())), Path((app.to_string(), "chat".to_string())),
Json(UpdateTopicRequest { Json(UpdateTopicRequest {
external_subscribable: Some(true), external_subscribable: Some(true),
auth_mode: None, auth_mode: None,
@@ -551,7 +551,7 @@ mod tests {
let Json(updated) = update_topic( let Json(updated) = update_topic(
State(s), State(s),
Extension(member()), Extension(member()),
Path((app, "chat".to_string())), Path((app.to_string(), "chat".to_string())),
Json(UpdateTopicRequest { Json(UpdateTopicRequest {
external_subscribable: Some(true), external_subscribable: Some(true),
auth_mode: Some(TopicAuthMode::Token), auth_mode: Some(TopicAuthMode::Token),
@@ -574,7 +574,7 @@ mod tests {
let status = delete_topic( let status = delete_topic(
State(s), State(s),
Extension(member()), Extension(member()),
Path((app, "chat".to_string())), Path((app.to_string(), "chat".to_string())),
) )
.await .await
.unwrap(); .unwrap();
@@ -596,7 +596,7 @@ mod tests {
let err = create_topic( let err = create_topic(
State(s), State(s),
Extension(member()), Extension(member()),
Path(app_b), Path(app_b.to_string()),
Json(CreateTopicRequest { Json(CreateTopicRequest {
name: "chat".into(), name: "chat".into(),
external_subscribable: true, external_subscribable: true,
@@ -615,7 +615,7 @@ mod tests {
let err = create_topic( let err = create_topic(
State(s), State(s),
Extension(member()), Extension(member()),
Path(app), Path(app.to_string()),
Json(CreateTopicRequest { Json(CreateTopicRequest {
name: "user.*".into(), name: "user.*".into(),
external_subscribable: true, external_subscribable: true,

View File

@@ -195,9 +195,9 @@ pub struct TriggerListResponse {
async fn list_triggers( async fn list_triggers(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
) -> Result<Json<TriggerListResponse>, TriggersApiError> { ) -> Result<Json<TriggerListResponse>, TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -249,10 +249,10 @@ fn map_script_repo_err(e: ScriptRepositoryError) -> TriggersApiError {
async fn create_kv_trigger( async fn create_kv_trigger(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Json(input): Json<CreateKvTriggerRequest>, Json(input): Json<CreateKvTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> { ) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -286,10 +286,10 @@ async fn create_kv_trigger(
async fn create_docs_trigger( async fn create_docs_trigger(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Json(input): Json<CreateDocsTriggerRequest>, Json(input): Json<CreateDocsTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> { ) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -323,10 +323,10 @@ async fn create_docs_trigger(
async fn create_cron_trigger( async fn create_cron_trigger(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Json(input): Json<CreateCronTriggerRequest>, Json(input): Json<CreateCronTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> { ) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -400,10 +400,10 @@ pub struct CreateQueueTriggerRequest {
async fn create_pubsub_trigger( async fn create_pubsub_trigger(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Json(input): Json<CreatePubsubTriggerRequest>, Json(input): Json<CreatePubsubTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> { ) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -435,10 +435,10 @@ async fn create_pubsub_trigger(
async fn create_files_trigger( async fn create_files_trigger(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Json(input): Json<CreateFilesTriggerRequest>, Json(input): Json<CreateFilesTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> { ) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -472,10 +472,10 @@ async fn create_files_trigger(
async fn create_dl_trigger( async fn create_dl_trigger(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Json(input): Json<CreateDeadLetterTriggerRequest>, Json(input): Json<CreateDeadLetterTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> { ) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -506,10 +506,10 @@ struct CreateEmailTriggerRequest {
async fn create_email_trigger( async fn create_email_trigger(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Json(input): Json<CreateEmailTriggerRequest>, Json(input): Json<CreateEmailTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> { ) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -551,10 +551,10 @@ async fn create_email_trigger(
async fn create_queue_trigger( async fn create_queue_trigger(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>, Path(id_or_slug): Path<String>,
Json(input): Json<CreateQueueTriggerRequest>, Json(input): Json<CreateQueueTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> { ) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?; let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require( require(
s.authz.as_ref(), s.authz.as_ref(),
&principal, &principal,
@@ -590,9 +590,9 @@ async fn create_queue_trigger(
async fn delete_trigger( async fn delete_trigger(
State(s): State<TriggersState>, State(s): State<TriggersState>,
Extension(principal): Extension<Principal>, Extension(principal): Extension<Principal>,
Path((app_id, trigger_id)): Path<(AppId, TriggerId)>, Path((id_or_slug, trigger_id)): Path<(String, TriggerId)>,
) -> Result<StatusCode, TriggersApiError> { ) -> Result<StatusCode, TriggersApiError> {
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 // Load the trigger so we can confirm it belongs to the right
// app; this prevents a caller from deleting a trigger by id alone // app; this prevents a caller from deleting a trigger by id alone
// when their capability is bound to a different app. // when their capability is bound to a different app.
@@ -616,15 +616,12 @@ async fn delete_trigger(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn ensure_app_exists( async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, TriggersApiError> {
apps: &dyn AppRepository, crate::app_repo::resolve_app(apps, ident)
app_id: AppId,
) -> Result<(), TriggersApiError> {
apps.get_by_id(app_id)
.await .await
.map_err(|e| TriggersApiError::Backend(e.to_string()))? .map_err(|e| TriggersApiError::Backend(e.to_string()))?
.ok_or_else(|| TriggersApiError::AppNotFound(app_id.to_string()))?; .map(|l| l.app.id)
Ok(()) .ok_or_else(|| TriggersApiError::AppNotFound(ident.to_string()))
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -1347,7 +1344,7 @@ mod tests {
let res = create_kv_trigger( let res = create_kv_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(AppId::new()), // a different (non-existent) app Path(AppId::new().to_string()), // a different (non-existent) app
Json(CreateKvTriggerRequest { Json(CreateKvTriggerRequest {
script_id: ScriptId::new(), script_id: ScriptId::new(),
collection_glob: "*".into(), collection_glob: "*".into(),
@@ -1370,7 +1367,7 @@ mod tests {
let res = create_kv_trigger( let res = create_kv_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateKvTriggerRequest { Json(CreateKvTriggerRequest {
script_id: ScriptId::new(), script_id: ScriptId::new(),
collection_glob: "*".into(), collection_glob: "*".into(),
@@ -1397,7 +1394,7 @@ mod tests {
let (status, Json(trigger)) = create_kv_trigger( let (status, Json(trigger)) = create_kv_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateKvTriggerRequest { Json(CreateKvTriggerRequest {
script_id, script_id,
collection_glob: "widgets".into(), collection_glob: "widgets".into(),
@@ -1422,7 +1419,7 @@ mod tests {
let res = create_kv_trigger( let res = create_kv_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateKvTriggerRequest { Json(CreateKvTriggerRequest {
script_id: ScriptId::new(), script_id: ScriptId::new(),
collection_glob: " ".into(), collection_glob: " ".into(),
@@ -1446,7 +1443,7 @@ mod tests {
let (status, Json(trigger)) = create_docs_trigger( let (status, Json(trigger)) = create_docs_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateDocsTriggerRequest { Json(CreateDocsTriggerRequest {
script_id, script_id,
collection_glob: "users".into(), collection_glob: "users".into(),
@@ -1483,7 +1480,7 @@ mod tests {
let res = create_docs_trigger( let res = create_docs_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateDocsTriggerRequest { Json(CreateDocsTriggerRequest {
script_id: ScriptId::new(), script_id: ScriptId::new(),
collection_glob: " ".into(), collection_glob: " ".into(),
@@ -1506,7 +1503,7 @@ mod tests {
let res = create_docs_trigger( let res = create_docs_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateDocsTriggerRequest { Json(CreateDocsTriggerRequest {
script_id: ScriptId::new(), script_id: ScriptId::new(),
collection_glob: "users".into(), collection_glob: "users".into(),
@@ -1589,7 +1586,7 @@ mod tests {
let res = delete_trigger( let res = delete_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path((app_b, trigger.id)), Path((app_b.to_string(), trigger.id)),
) )
.await; .await;
let err = res.expect_err("cross-app delete should 404"); let err = res.expect_err("cross-app delete should 404");
@@ -1615,7 +1612,7 @@ mod tests {
let res = create_kv_trigger( let res = create_kv_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateKvTriggerRequest { Json(CreateKvTriggerRequest {
script_id, script_id,
collection_glob: "widgets".into(), collection_glob: "widgets".into(),
@@ -1653,7 +1650,7 @@ mod tests {
let res = create_docs_trigger( let res = create_docs_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateDocsTriggerRequest { Json(CreateDocsTriggerRequest {
script_id, script_id,
collection_glob: "users".into(), collection_glob: "users".into(),
@@ -1688,7 +1685,7 @@ mod tests {
let res = create_dl_trigger( let res = create_dl_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateDeadLetterTriggerRequest { Json(CreateDeadLetterTriggerRequest {
script_id, script_id,
source_filter: None, source_filter: None,
@@ -1713,7 +1710,7 @@ mod tests {
let res = create_kv_trigger( let res = create_kv_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateKvTriggerRequest { Json(CreateKvTriggerRequest {
script_id: ScriptId::new(), script_id: ScriptId::new(),
collection_glob: "widgets".into(), collection_glob: "widgets".into(),
@@ -1754,7 +1751,7 @@ mod tests {
let res = create_kv_trigger( let res = create_kv_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_a), Path(app_a.to_string()),
Json(CreateKvTriggerRequest { Json(CreateKvTriggerRequest {
script_id, script_id,
collection_glob: "widgets".into(), collection_glob: "widgets".into(),
@@ -1801,7 +1798,7 @@ mod tests {
let (status, Json(trigger)) = create_cron_trigger( let (status, Json(trigger)) = create_cron_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(cron_req( Json(cron_req(
script_id, script_id,
"0 0 9 * * MON-FRI", "0 0 9 * * MON-FRI",
@@ -1837,7 +1834,7 @@ mod tests {
let res = create_cron_trigger( let res = create_cron_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
// 5-field expression — not the 6-field format we accept. // 5-field expression — not the 6-field format we accept.
Json(cron_req(script_id, "* * * * *", "UTC")), Json(cron_req(script_id, "* * * * *", "UTC")),
) )
@@ -1858,7 +1855,7 @@ mod tests {
let res = create_cron_trigger( let res = create_cron_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(cron_req(script_id, "0 * * * * *", "Mars/Phobos")), Json(cron_req(script_id, "0 * * * * *", "Mars/Phobos")),
) )
.await; .await;
@@ -1885,7 +1882,7 @@ mod tests {
let res = create_cron_trigger( let res = create_cron_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(cron_req(script_id, "0 * * * * *", "UTC")), Json(cron_req(script_id, "0 * * * * *", "UTC")),
) )
.await; .await;
@@ -1915,7 +1912,7 @@ mod tests {
let res = create_cron_trigger( let res = create_cron_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_a), Path(app_a.to_string()),
Json(cron_req(script_id, "0 * * * * *", "UTC")), Json(cron_req(script_id, "0 * * * * *", "UTC")),
) )
.await; .await;
@@ -1934,7 +1931,7 @@ mod tests {
let res = create_cron_trigger( let res = create_cron_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(cron_req(ScriptId::new(), "0 * * * * *", "UTC")), Json(cron_req(ScriptId::new(), "0 * * * * *", "UTC")),
) )
.await; .await;
@@ -1950,7 +1947,7 @@ mod tests {
let (status, _) = create_kv_trigger( let (status, _) = create_kv_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(CreateKvTriggerRequest { Json(CreateKvTriggerRequest {
script_id, script_id,
collection_glob: "widgets".into(), collection_glob: "widgets".into(),
@@ -1990,7 +1987,7 @@ mod tests {
let (status, Json(trigger)) = create_files_trigger( let (status, Json(trigger)) = create_files_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(files_req(script_id, "avatars")), Json(files_req(script_id, "avatars")),
) )
.await .await
@@ -2019,7 +2016,7 @@ mod tests {
let res = create_files_trigger( let res = create_files_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(files_req(ScriptId::new(), " ")), Json(files_req(ScriptId::new(), " ")),
) )
.await; .await;
@@ -2044,7 +2041,7 @@ mod tests {
let res = create_files_trigger( let res = create_files_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(files_req(script_id, "avatars")), Json(files_req(script_id, "avatars")),
) )
.await; .await;
@@ -2071,7 +2068,7 @@ mod tests {
let res = create_files_trigger( let res = create_files_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_a), Path(app_a.to_string()),
Json(files_req(script_id, "avatars")), Json(files_req(script_id, "avatars")),
) )
.await; .await;
@@ -2089,7 +2086,7 @@ mod tests {
let res = create_files_trigger( let res = create_files_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(files_req(ScriptId::new(), "avatars")), Json(files_req(ScriptId::new(), "avatars")),
) )
.await; .await;
@@ -2118,7 +2115,7 @@ mod tests {
let (status, Json(trigger)) = create_pubsub_trigger( let (status, Json(trigger)) = create_pubsub_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(pubsub_req(script_id, "user.*")), Json(pubsub_req(script_id, "user.*")),
) )
.await .await
@@ -2139,7 +2136,7 @@ mod tests {
let res = create_pubsub_trigger( let res = create_pubsub_trigger(
State(state.clone()), State(state.clone()),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(pubsub_req(script_id, bad)), Json(pubsub_req(script_id, bad)),
) )
.await; .await;
@@ -2169,7 +2166,7 @@ mod tests {
let res = create_pubsub_trigger( let res = create_pubsub_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(pubsub_req(script_id, "user.*")), Json(pubsub_req(script_id, "user.*")),
) )
.await; .await;
@@ -2196,7 +2193,7 @@ mod tests {
let res = create_pubsub_trigger( let res = create_pubsub_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_a), Path(app_a.to_string()),
Json(pubsub_req(script_id, "user.*")), Json(pubsub_req(script_id, "user.*")),
) )
.await; .await;
@@ -2214,7 +2211,7 @@ mod tests {
let res = create_pubsub_trigger( let res = create_pubsub_trigger(
State(state), State(state),
Extension(member_principal()), Extension(member_principal()),
Path(app_id), Path(app_id.to_string()),
Json(pubsub_req(ScriptId::new(), "user.*")), Json(pubsub_req(ScriptId::new(), "user.*")),
) )
.await; .await;

View File

@@ -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 `<slug>`" 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();
});
});