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:
MechaCat02
2026-06-09 20:03:10 +02:00
parent c42a8406b4
commit a1b7569d05
9 changed files with 399 additions and 90 deletions

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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);

View File

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