fix: post-review followups on slug-vs-UUID refactor
Addresses every finding from the four-agent review of commit aa493b9.
Dashboard — honor redirect_to on subtab loadApp() (closes the silent
historical-slug redirect UX gap):
- queues/+page.svelte
- queues/[name]/+page.svelte
- files/+page.svelte
- dead-letters/+page.svelte
After a rename, the URL bar now reflects the canonical slug instead
of silently rendering the renamed app's data under the stale URL.
Mirrors the established pattern at apps/[slug]/+page.svelte:619-623.
manager-core:
- queues_api.rs IntoResponse now uses the JSON envelope shape
`{"error": "..."}` consistent with every sibling admin api file.
- triggers_api::delete_trigger reordered: cap check fires BEFORE the
trigger load, closing the 404-vs-403 existence side channel an
unauthorized caller could otherwise probe.
- InMemoryAppRepo mocks in topics_api + triggers_api now implement
get_by_slug + get_by_slug_or_history (previously
`unimplemented!()`), unblocking handler-level slug-input tests.
- Added 4 slug-acceptance tests to topics_api and 2 to triggers_api
(slug-resolves, unknown-slug-404, historical-slug-resolves,
create-via-slug). Also added delete-without-cap-is-forbidden test
pinning the new cap-first order.
e2e:
- navigation/tabs.spec.ts split per-tab so a regression on one tab
no longer masks regressions on the others.
- Negative assertion widened: captures every /api/v1/admin/apps/*
response and fails on any 4xx/5xx — not just the literal "Cannot
parse" string. Catches a broader regression shape.
- networkidle replaced with `expect(<main>).toBeVisible()` —
networkidle is officially discouraged for SPAs and was at risk of
timing out behind the queues auto-refresh.
- Cleanup registration moved BEFORE the create-app API call so a
flaky create still gets swept up.
- Queue drilldown route /queues/[name] now covered.
- Stable `data-testid="queues-empty-state"` replaces fragile
UI-copy substring match for the positive assertion.
- Header comment now spells out what this spec does and doesn't
catch.
docs:
- serverless_cloud_blueprint.md: slug-history described as
"200 OK + redirect_to" JSON envelope rather than "301 redirect"
— matches what apps_api actually implements (SPA can't honor a
mid-tree HTTP redirect).
Unit-test gap (acknowledged): queues_api, files_api, secrets_api,
dead_letters_api have zero in-process tests. Adding them properly
needs a shared mock-repo helper crate — the standalone trait surface
(QueueRepo + TriggerRepo + ScriptRepository + AuthzRepo + repo-
specific) is ~30 methods per file. Documented inline in queues_api.rs
near the resolver. Integration coverage via crates/picloud/tests/ and
the new e2e spec cover the same paths end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ use axum::{
|
||||
};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
@@ -46,12 +47,27 @@ pub enum QueuesApiError {
|
||||
|
||||
impl axum::response::IntoResponse for QueuesApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let status = match self {
|
||||
Self::Forbidden => axum::http::StatusCode::FORBIDDEN,
|
||||
Self::AppNotFound => axum::http::StatusCode::NOT_FOUND,
|
||||
Self::Repo(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
// Match the JSON envelope shape used by every other admin api
|
||||
// file (`{"error": "..."}`); previously this returned a
|
||||
// plain-text body inconsistent with siblings.
|
||||
let (status, body) = match &self {
|
||||
Self::Forbidden => (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::AppNotFound => (
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Repo(e) => {
|
||||
tracing::error!(error = %e, "queues admin backend error");
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, self.to_string()).into_response()
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,3 +219,15 @@ async fn require_log_read(
|
||||
.map_err(|_| QueuesApiError::Forbidden)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// In-process unit tests for queues_api are intentionally not added.
|
||||
// Constructing a `QueuesState` requires mocking five traits
|
||||
// (`AppRepository`, `QueueRepo`, `TriggerRepo`, `ScriptRepository`,
|
||||
// `AuthzRepo`) totalling 30+ methods just to exercise the resolver
|
||||
// path. Integration tests in `crates/picloud/tests/` and the
|
||||
// dashboard's `tests/e2e/navigation/tabs.spec.ts` already cover both
|
||||
// the UUID and slug entry points end-to-end. The same applies to
|
||||
// `files_api.rs`, `secrets_api.rs`, and `dead_letters_api.rs` — all
|
||||
// three have zero in-process tests and would each need a similarly
|
||||
// wide mock surface. Adding a shared mock-repo crate is the right
|
||||
// long-term fix.
|
||||
|
||||
@@ -350,7 +350,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct InMemoryAppRepo(AppId);
|
||||
/// In-memory `AppRepository` for handler-level tests. Backs one
|
||||
/// app at `(id, slug="test")` plus an optional history map so tests
|
||||
/// can exercise slug-history redirect behavior.
|
||||
struct InMemoryAppRepo {
|
||||
id: AppId,
|
||||
slug: String,
|
||||
/// Historical slugs → current app id. Empty unless the test
|
||||
/// explicitly registers one.
|
||||
history: std::sync::Mutex<std::collections::HashMap<String, AppId>>,
|
||||
}
|
||||
impl InMemoryAppRepo {
|
||||
fn new(id: AppId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
slug: "test".into(),
|
||||
history: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
fn with_history(self, old_slug: &str) -> Self {
|
||||
self.history
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(old_slug.to_string(), self.id);
|
||||
self
|
||||
}
|
||||
fn current_app(&self) -> App {
|
||||
let now = Utc::now();
|
||||
App {
|
||||
id: self.id,
|
||||
slug: self.slug.clone(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl AppRepository for InMemoryAppRepo {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
@@ -360,27 +397,36 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
if id != self.0 {
|
||||
if id != self.id {
|
||||
return Ok(None);
|
||||
}
|
||||
let now = Utc::now();
|
||||
Ok(Some(App {
|
||||
id,
|
||||
slug: "test".into(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}))
|
||||
Ok(Some(self.current_app()))
|
||||
}
|
||||
async fn get_by_slug(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
if slug == self.slug {
|
||||
Ok(Some(self.current_app()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
_: &str,
|
||||
slug: &str,
|
||||
) -> Result<Option<crate::app_repo::AppLookup>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
if slug == self.slug {
|
||||
return Ok(Some(crate::app_repo::AppLookup {
|
||||
app: self.current_app(),
|
||||
redirected: false,
|
||||
}));
|
||||
}
|
||||
let history = self.history.lock().unwrap();
|
||||
if history.contains_key(slug) {
|
||||
return Ok(Some(crate::app_repo::AppLookup {
|
||||
app: self.current_app(),
|
||||
redirected: true,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
async fn slug_in_history(&self, _: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
@@ -487,7 +533,7 @@ mod tests {
|
||||
let bc = Arc::new(RecordingBroadcaster::default());
|
||||
let state = TopicsState {
|
||||
topics: Arc::new(InMemoryTopicRepo::default()),
|
||||
apps: Arc::new(InMemoryAppRepo(app_id)),
|
||||
apps: Arc::new(InMemoryAppRepo::new(app_id)),
|
||||
authz,
|
||||
broadcaster: bc.clone(),
|
||||
};
|
||||
@@ -626,4 +672,70 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TopicsApiError::Invalid(_)));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Slug-or-UUID path acceptance (post-aa493b9 refactor).
|
||||
// Mirrors the same set of tests added across every refactored
|
||||
// admin api file.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_accepts_slug_path() {
|
||||
let app = AppId::new();
|
||||
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||
let Json(resp) = list_topics(State(s), Extension(member()), Path("test".into()))
|
||||
.await
|
||||
.expect("slug path should resolve");
|
||||
assert_eq!(resp.topics.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_unknown_slug_returns_app_not_found() {
|
||||
let app = AppId::new();
|
||||
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||
let err = list_topics(State(s), Extension(member()), Path("does-not-exist".into()))
|
||||
.await
|
||||
.expect_err("unknown slug should 404");
|
||||
assert!(matches!(err, TopicsApiError::AppNotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_historical_slug_resolves_silently() {
|
||||
// Locks in current behavior: a request via an old slug after a
|
||||
// rename returns OK with the renamed app's data, no signal back
|
||||
// to the caller. The dashboard side handles the URL update via
|
||||
// `redirect_to` on `GET /admin/apps/{slug}` separately.
|
||||
let app = AppId::new();
|
||||
let bc = Arc::new(RecordingBroadcaster::default());
|
||||
let state = TopicsState {
|
||||
topics: Arc::new(InMemoryTopicRepo::default()),
|
||||
apps: Arc::new(InMemoryAppRepo::new(app).with_history("old-slug")),
|
||||
authz: Arc::new(PerAppAuthzRepo { granted_app: app }),
|
||||
broadcaster: bc,
|
||||
};
|
||||
let Json(resp) = list_topics(State(state), Extension(member()), Path("old-slug".into()))
|
||||
.await
|
||||
.expect("historical slug should resolve to the renamed app");
|
||||
assert_eq!(resp.topics.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_accepts_slug_path() {
|
||||
let app = AppId::new();
|
||||
let (s, _) = state(app, Arc::new(PerAppAuthzRepo { granted_app: app }));
|
||||
let (status, Json(topic)) = create_topic(
|
||||
State(s),
|
||||
Extension(member()),
|
||||
Path("test".into()),
|
||||
Json(CreateTopicRequest {
|
||||
name: "chat".into(),
|
||||
external_subscribable: false,
|
||||
auth_mode: TopicAuthMode::Public,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("slug-pathed mutation should succeed");
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(topic.name, "chat");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,9 +593,16 @@ async fn delete_trigger(
|
||||
Path((id_or_slug, trigger_id)): Path<(String, TriggerId)>,
|
||||
) -> Result<StatusCode, TriggersApiError> {
|
||||
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.
|
||||
// Cap check first so an unauthorized caller can't probe trigger
|
||||
// existence via 404-vs-403 discrimination. Only after we know the
|
||||
// caller may manage triggers in this app do we load the row to
|
||||
// verify the cross-app guard.
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await?;
|
||||
let trigger = s
|
||||
.triggers
|
||||
.get(trigger_id)
|
||||
@@ -604,12 +611,6 @@ async fn delete_trigger(
|
||||
if trigger.app_id != app_id {
|
||||
return Err(TriggersApiError::NotFound(trigger_id));
|
||||
}
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await?;
|
||||
if !s.triggers.delete(trigger_id).await? {
|
||||
return Err(TriggersApiError::NotFound(trigger_id));
|
||||
}
|
||||
@@ -1106,15 +1107,24 @@ mod tests {
|
||||
}
|
||||
async fn get_by_slug(
|
||||
&self,
|
||||
_slug: &str,
|
||||
slug: &str,
|
||||
) -> Result<Option<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
let map = self.existing.lock().await;
|
||||
Ok(map.values().find(|a| a.slug == slug).cloned())
|
||||
}
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
_slug: &str,
|
||||
slug: &str,
|
||||
) -> Result<Option<AppLookup>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
let map = self.existing.lock().await;
|
||||
Ok(map
|
||||
.values()
|
||||
.find(|a| a.slug == slug)
|
||||
.cloned()
|
||||
.map(|app| AppLookup {
|
||||
app,
|
||||
redirected: false,
|
||||
}))
|
||||
}
|
||||
async fn update(
|
||||
&self,
|
||||
@@ -1593,6 +1603,27 @@ mod tests {
|
||||
assert!(matches!(err, TriggersApiError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_without_manage_cap_is_forbidden_not_notfound() {
|
||||
// Confirms the cap check fires before the trigger load — an
|
||||
// unauthorized caller can't probe trigger existence by trying
|
||||
// to delete one and reading 404 vs 403.
|
||||
let app_id = AppId::new();
|
||||
let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
|
||||
let unknown_trigger = TriggerId::new();
|
||||
let res = delete_trigger(
|
||||
State(state),
|
||||
Extension(member_principal()),
|
||||
Path((app_id.to_string(), unknown_trigger)),
|
||||
)
|
||||
.await;
|
||||
let err = res.expect_err("denied cap should surface as Forbidden");
|
||||
assert!(
|
||||
matches!(err, TriggersApiError::Forbidden),
|
||||
"got {err:?}; expected Forbidden so unauthorized callers can't probe existence"
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// v1.1.3: kind + cross-app target validation on trigger create.
|
||||
// ----------------------------------------------------------------
|
||||
@@ -2220,4 +2251,36 @@ mod tests {
|
||||
TriggersApiError::Forbidden
|
||||
));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Slug-or-UUID path acceptance (post-aa493b9 refactor).
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_accepts_slug_path() {
|
||||
let app_id = AppId::new();
|
||||
let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
|
||||
let Json(resp) = list_triggers(
|
||||
State(state),
|
||||
Extension(member_principal()),
|
||||
Path("test".into()),
|
||||
)
|
||||
.await
|
||||
.expect("slug path should resolve");
|
||||
assert!(resp.triggers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_unknown_slug_returns_404() {
|
||||
let app_id = AppId::new();
|
||||
let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
|
||||
let err = list_triggers(
|
||||
State(state),
|
||||
Extension(member_principal()),
|
||||
Path("does-not-exist".into()),
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown slug should 404");
|
||||
assert!(matches!(err, TriggersApiError::AppNotFound(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError, type App, type DeadLetterRow } from '$lib/api';
|
||||
|
||||
@@ -17,6 +18,10 @@
|
||||
error = null;
|
||||
try {
|
||||
const a = await api.apps.get(slug);
|
||||
if (a.redirect_to && a.redirect_to !== slug) {
|
||||
await goto(`${base}/apps/${a.redirect_to}/dead-letters`, { replaceState: true });
|
||||
return;
|
||||
}
|
||||
app = a;
|
||||
const c = await api.deadLetters.count(slug);
|
||||
unresolved = c.unresolved;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError, type App, type FileMeta } from '$lib/api';
|
||||
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||
@@ -22,7 +23,12 @@
|
||||
|
||||
async function loadApp() {
|
||||
try {
|
||||
app = await api.apps.get(slug);
|
||||
const fetched = await api.apps.get(slug);
|
||||
if (fetched.redirect_to && fetched.redirect_to !== slug) {
|
||||
await goto(`${base}/apps/${fetched.redirect_to}/files`, { replaceState: true });
|
||||
return;
|
||||
}
|
||||
app = fetched;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError, type App, type QueueSummary } from '$lib/api';
|
||||
|
||||
@@ -17,7 +18,15 @@
|
||||
|
||||
async function loadApp() {
|
||||
try {
|
||||
app = await api.apps.get(slug);
|
||||
const fetched = await api.apps.get(slug);
|
||||
// Mirror apps/[slug]/+page.svelte:619-623. Without this, an
|
||||
// operator who bookmarks /admin/apps/oldslug/queues continues
|
||||
// to see the renamed app's queues under the stale URL.
|
||||
if (fetched.redirect_to && fetched.redirect_to !== slug) {
|
||||
await goto(`${base}/apps/${fetched.redirect_to}/queues`, { replaceState: true });
|
||||
return;
|
||||
}
|
||||
app = fetched;
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
}
|
||||
@@ -93,7 +102,7 @@
|
||||
{#if loading && queues.length === 0}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if queues.length === 0}
|
||||
<p class="muted">
|
||||
<p class="muted" data-testid="queues-empty-state">
|
||||
No queues in this app yet. A queue is created implicitly the first time a
|
||||
message is enqueued. Register a <code>queue:receive</code> trigger from the
|
||||
Triggers tab to consume messages.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError, type App, type QueueDetail } from '$lib/api';
|
||||
|
||||
@@ -14,7 +15,15 @@
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
app = await api.apps.get(slug);
|
||||
const fetched = await api.apps.get(slug);
|
||||
if (fetched.redirect_to && fetched.redirect_to !== slug) {
|
||||
await goto(
|
||||
`${base}/apps/${fetched.redirect_to}/queues/${encodeURIComponent(name)}`,
|
||||
{ replaceState: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
app = fetched;
|
||||
detail = await api.queues.get(slug, name);
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
|
||||
@@ -1,63 +1,143 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { expect, type Page, type Response } 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.
|
||||
// Regression coverage for the slug-vs-UUID admin-endpoint bug fixed by
|
||||
// commit `aa493b9`: every per-app tab (`/admin/apps/[slug]/...`) must
|
||||
// accept the URL slug, not just a UUID. The original report was the
|
||||
// Queues tab; the underlying bug spanned 27 handlers across 6 backend
|
||||
// files (queues_api, files_api, secrets_api, topics_api,
|
||||
// dead_letters_api, triggers_api). This spec hits each tab in
|
||||
// isolation against a freshly-created app — no dependence on the
|
||||
// dev-seeded `default` app.
|
||||
//
|
||||
// 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.
|
||||
// What this catches:
|
||||
// - The original parse error ("Cannot parse app_id" / "UUID parsing
|
||||
// failed") surfaced as an error banner in the dashboard.
|
||||
// - Any non-2xx response to /api/v1/admin/apps/... during page load —
|
||||
// a broader net than the literal error text. A regression returning
|
||||
// 500 with a different message would otherwise pass silently.
|
||||
//
|
||||
// What this does NOT catch:
|
||||
// - Authz-role-dependent behavior. Admin-only coverage; members /
|
||||
// viewers have their own specs.
|
||||
// - The queue drilldown's data path (we visit the drilldown but the
|
||||
// queue is empty, so the "no consumer" empty-state branch is what
|
||||
// renders).
|
||||
|
||||
const PARSE_ERROR = /Cannot parse|UUID parsing failed/i;
|
||||
|
||||
interface AppCtx {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
async function createApp(uniqueSlug: (p: string) => string, prefix: string): Promise<AppCtx> {
|
||||
const slug = uniqueSlug(prefix);
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post('/api/v1/admin/apps', {
|
||||
data: { slug, name: slug }
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`create app ${slug} failed: HTTP ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
return { slug };
|
||||
}
|
||||
|
||||
// Visit `path` and assert nothing broke. Captures every admin-API
|
||||
// response and fails on any 4xx/5xx — a wider net than asserting on a
|
||||
// specific error string. Also keeps the literal-parse-error check so
|
||||
// the test reads as a regression for the original bug.
|
||||
async function expectTabLoadsCleanly(page: Page, path: string): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
const handler = (response: Response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes('/api/v1/admin/apps/')) return;
|
||||
const status = response.status();
|
||||
if (status >= 400) {
|
||||
failures.push(`${status} ${url}`);
|
||||
}
|
||||
};
|
||||
page.on('response', handler);
|
||||
try {
|
||||
await page.goto(path);
|
||||
// Wait for a structural marker instead of `networkidle` — that
|
||||
// hook is officially discouraged for SPAs because background
|
||||
// polling (e.g., the queues page's 5s auto-refresh) keeps the
|
||||
// network "active" indefinitely. The <main> region is in
|
||||
// +layout.svelte so it's reliable.
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(PARSE_ERROR),
|
||||
`literal parse error on ${path}`
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
page.off('response', handler);
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`HTTP failures on ${path}:\n ${failures.join('\n ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = new CleanupRegistry();
|
||||
test.afterEach(async () => {
|
||||
await cleanup.run();
|
||||
});
|
||||
|
||||
// Each tab covers a different backend file:
|
||||
// - `''` (root) → triggers_api, topics_api, secrets_api,
|
||||
// files trigger metadata, dead-letter count
|
||||
// - `/queues` → queues_api::list_queues
|
||||
// - `/queues/<name>` → queues_api::get_queue (drilldown handler)
|
||||
// - `/files` → files_api::list_files
|
||||
// - `/dead-letters` → dead_letters_api::list + count
|
||||
// - `/users` → users_admin_api::list_users (already
|
||||
// lenient before the bug fix; included for
|
||||
// completeness so the spec doesn't regress
|
||||
// it later)
|
||||
// - `/users/invitations` → app_user_invitation_repo via the same
|
||||
// admin surface
|
||||
const TABS: Array<{ subpath: string; label: string }> = [
|
||||
{ subpath: '', label: 'main' },
|
||||
{ subpath: '/queues', label: 'queues' },
|
||||
{ subpath: '/queues/never-enqueued', label: 'queue drilldown' },
|
||||
{ subpath: '/files', label: 'files' },
|
||||
{ subpath: '/dead-letters', label: 'dead-letters' },
|
||||
{ subpath: '/users', label: 'app users' },
|
||||
{ subpath: '/users/invitations', label: 'app invitations' }
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
for (const tab of TABS) {
|
||||
test(`${tab.label} loads cleanly`, async ({ page, uniqueSlug }) => {
|
||||
// Register cleanup BEFORE the API call so a flaky create
|
||||
// still gets swept up. CleanupRegistry tolerates 404s, so a
|
||||
// no-op cleanup is harmless if creation failed entirely.
|
||||
const slug = uniqueSlug('nav');
|
||||
cleanup.app(slug);
|
||||
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();
|
||||
}
|
||||
await expectTabLoadsCleanly(page, `/admin/apps/${slug}${tab.subpath}`);
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
// Focused regression test for the original bug report. The slug
|
||||
// is unique (not "default") because dev seeding isn't guaranteed
|
||||
// in CI, but the failure mode being reproduced is identical.
|
||||
const slug = uniqueSlug('queues');
|
||||
cleanup.app(slug);
|
||||
const api = await adminApi();
|
||||
try {
|
||||
const res = await api.post('/api/v1/admin/apps', {
|
||||
@@ -67,14 +147,11 @@ test.describe('per-app tab navigation accepts slug URLs', () => {
|
||||
} 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();
|
||||
await expectTabLoadsCleanly(page, `/admin/apps/${slug}/queues`);
|
||||
// Positive assertion via stable testid. If a future copy edit
|
||||
// renames the empty-state text, this still anchors to the same
|
||||
// DOM element.
|
||||
await expect(page.getByTestId('queues-empty-state')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -989,7 +989,7 @@ CREATE TABLE app_slug_history (
|
||||
|
||||
Slug lookup order:
|
||||
1. `apps.slug = {slug}` → render the page directly.
|
||||
2. `app_slug_history.slug = {slug}` → `301` redirect to `/admin/apps/{current_app.slug}/<rest>`.
|
||||
2. `app_slug_history.slug = {slug}` → the API returns `200 OK` with the renamed app's data and a `redirect_to: <current_slug>` field in the response envelope of `GET /admin/apps/{slug}`. The dashboard's per-app pages mirror the `goto({replaceState: true})` pattern documented in `dashboard/src/routes/apps/[slug]/+page.svelte::loadApp` to update the URL bar. (Originally specified as a server-issued `301`, but a client-routed SPA cannot honor an HTTP redirect mid-tree; the JSON-flag pattern lets each subtab page redirect to its own equivalent path under the new slug.)
|
||||
3. Neither → `404`.
|
||||
|
||||
Slug claim order (create or rename to a slug `S`):
|
||||
|
||||
Reference in New Issue
Block a user