The single bare-metal integration test now reuses a `LazyLock<Fixture>`
that spawns picloud once on a private port and shares it across every
test in the binary. Sets the stage for per-surface journey modules
(auth, apps, scripts, invoke, logs, roles, output) without each one
paying for its own server spawn — same trick the dashboard Playwright
suite uses with global-setup.
Notes:
- `tests/cli.rs` becomes a tiny module list; the seed flow moved to
`tests/integration.rs`. The seed slug now goes through
`common::unique_slug` so parallel/serial reruns can't collide.
- `autotests = false` + an explicit `[[test]] name = "cli"` keeps Cargo
from auto-promoting future `tests/*.rs` files into their own binaries
(which would each respawn picloud).
- Subprocess cleanup uses `libc::atexit` to SIGTERM picloud when the
test binary exits. PR_SET_PDEATHSIG was tried and rejected: it fires
when the *thread* that forked dies, and cargo's per-test worker
threads exit between tests, which killed the fixture mid-suite.
- New helpers: AppGuard/UserGuard (RAII teardown), member_user /
grant_membership / update_membership (direct API for role tests),
unique_slug / unique_username, pic_as / pic_no_env.
- Two `fixture_url_is_shared_*` tests prove the LazyLock is actually
shared, not respawned per test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A trailing fmt drift on tests/cli.rs:95 — `format!()` arg was wrapped
across three lines where rustfmt wants one. Running `cargo fmt --all`
collapses it; no behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spawns the pre-built `picloud` binary against DATABASE_URL on a
private port, logs in over HTTP to mint a bearer token, then drives
`pic` through the full edit-deploy-invoke-tail loop with a unique
app slug per run and a `Drop`-based cleanup. Gated on DATABASE_URL
and tagged `#[ignore]` to match the existing integration-test
pattern in `crates/picloud/tests/api.rs`.
The test uses the dev `admin/admin` credentials (overridable via
PICLOUD_CLI_E2E_USERNAME / _PASSWORD) because the bootstrap env
vars are inert once the DB has any admin row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new workspace crate `picloud-cli` shipping a `pic` binary that
drives the edit-deploy-invoke-tail-logs loop against PiCloud's admin
and execute HTTP surface. Eight subcommands cover the minimum a
developer needs to never open the dashboard:
pic login (paste URL + bearer token, validates via /auth/me)
pic whoami (re-validates and prints principal)
pic apps ls | create
pic scripts ls | deploy | invoke
pic logs <id>
Credentials persist as TOML under the platform config dir (resolved
via `directories`); on POSIX the file is forced to mode 0600.
PICLOUD_URL + PICLOUD_TOKEN env vars short-circuit interactive prompts
for CI and integration tests.
The CLI redeclares minimal request/response structs in `client.rs`
rather than depending on `manager-core` — keeps the blast radius
contained without touching the existing crate boundaries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test creates a script in the default app earlier in the body, so a
plain DELETE /apps/default hits the soft no-cascade guard and 409s
before the capability check runs. The intent is to validate that admin
holds AppAdmin everywhere, not to exercise the cascade contract — pass
?force=true so we reach the gate we're trying to test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CleanupRegistry's catch-all was masking every kind of teardown error,
not just the intended "resource already gone" 404. A backend returning
500 on delete would leak orphans run after run without ever surfacing.
Now treat 2xx and 404 as success, log any other status (and any
thrown network error) to stderr with the resource label, and keep
running the remaining items. The suite stays best-effort but no
longer hides accumulating leaks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Other services in the prod overlay already have it. Without it, a
`docker compose stop caddy` followed by `docker compose up -d` doesn't
bring caddy back up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Date.now() can collide across workers running on the same millisecond
boundary. The worker-aware helper that the rest of the suite uses
side-steps that without changing the test's intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Array.reverse mutates in place — a defensive double-run() would have
re-reversed the items. Iterate over a copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cancels once to assert the modal can be dismissed without side
effects, then confirms to flip the user to inactive, then reactivates
to assert that direction remains one-click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifts loginAsUserToken + pageWithUserToken out of members.spec.ts into
fixtures/role-page.ts (third file that needs them). Adds shadowing
coverage: viewer member sees no New-app / Add-domain / Settings / Save
/ +Add-route, editor sees Save but no Delete header, and CodeMirror
renders contenteditable=false for viewers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wipes e2e-* apps and e2e* admin users before the suite starts so a
prior crashed run doesn't accumulate state across runs (45 rows
observed on 2026-05-28). Per-row try/catch keeps it best-effort; a
sweep failure never blocks the suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches to Uint8 rejection sampling against the largest multiple of
the charset length that fits in a byte. Eliminates the ~16 ppm
overweight the previous `% N` over Uint32 would otherwise leave on the
first 38 chars. Adds a vitest distribution check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deactivation signs the user out and expires every API key they hold —
warrants a styled confirm. Reactivation stays one-click since it's
non-destructive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces window.confirm + alert() with the in-dashboard ConfirmModal
(danger variant, name-retype). Body summarises what gets removed
(routes + execution logs) and embeds the API error inline rather than
firing a native alert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures my_role off the existing parent-app fetch (no extra HTTP call)
and uses canWriteApp / canAdminApp to hide: header Delete, Edit Save +
Format, Routing +Add route + per-row remove, and the Settings tab.
CodeEditor renders read-only for viewers. An effect bounces a stale
Settings tab back to Edit for non-admins.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apps list: hide "New app" for members. App detail: hide New script for
viewers, Add domain + per-row Delete for non-admins, and the Members +
Settings tabs entirely for non-admins (with an effect that bounces a
stale activeTab back to Scripts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Threads readOnly through to EditorState.readOnly + EditorView.editable so
script-detail can render a viewer-only editor without intercepting
keystrokes upstream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure-function module that mirrors crates/manager-core/src/authz.rs and
lets dashboard pages decide which create / edit / delete affordances to
render. Widens the vitest include so the truth-table test runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the canonical capability rules with how the dashboard now shadows
its UI. Instance admins become implicit app_admin on every app (only
InstanceManageSettings stays owner-only), and the script-delete handler
moves from AppWriteScript to AppAdmin so editors can save but not delete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two scenarios that span the dashboard UI and the data/control plane
end-to-end:
- App + domain claim + script + route all created via the dashboard,
then the script is invoked through the public URL with the
matching Host header. Verifies the dashboard actions actually
reach the orchestrator's route trie.
- API key minted via the dashboard, then used as a bearer token
against /api/v1/admin/* (the CLI surface). Confirms the scope is
enforced (script:read passes /scripts, 403s /admins) and that
revoking via the dashboard immediately invalidates the token.
Also: the B7 copy-token test selected the mint-form Name input via
getByLabel('Name'), which became ambiguous once the integration
test created an app and the Binding dropdown was no longer empty.
Switched both B7 mint flows to placeholder-based selectors.
Suite: 57/57 passing in ~18s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues found while running the full B1–B8 suite together:
- The B1 logout test was driving the shared admin storageState
token, invalidating it for every subsequent test. Switched it to
a fresh login so its session is disposable.
- Bumped navigationTimeout to 30s and capped local workers at 4 to
cope with the Vite dev server's first-compile cost under
parallel load. Local also gets one retry to absorb intermittent
warmup flakiness.
- Cleared a few lint warnings (unused appId / _adminPage vars) and
belt-and-braces gitignore for playwright artifacts written to
the repo root when the CLI is invoked from there by accident.
Suite now: 55/55 passing in ~21s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five tests covering platform-wide guarantees: expired-token
redirect, HttpOnly session cookie, bootstrap password not leaked
into the DOM after login, missing-app slug fails gracefully, and
an XSS-sink probe across the main authed routes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six tests covering /admin/profile: mint instance-wide key with the
reveal/ack flow, the app-binding mutual-exclusion guard (instance
scopes auto-disabled), revoke via the ConfirmModal, the
?denied=users banner, plus adversarial cases (empty-name button
disabled, copy-token writes the full token to the clipboard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four tests covering the Members tab: invite + remove (action-menu +
phrase modal), role change, the non-app-admin viewer who never sees
the Members tab at all (cross-context via a second admin login),
and an adversarial that the role dropdown only exposes the
documented set of values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven tests covering the Routing tab inside the script editor: add
+ list + remove (handling the window.confirm dialog), match-preview
round trip, path-kind mismatch warning, unclaimed-host warning,
duplicate-route 409, plus reserved-prefix rejection and a path-XSS
adversarial that checks no script tag escapes into the route list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven tests covering script creation via the Scripts tab, the source
editor (CodeMirror typing + save + reload), Format-button error
surfaces for both Rhai and the test-invoke JSON body, the test-invoke
happy path, settings input validation, and an infinite-loop adversarial
that asserts the sandbox timeout reports cleanly and the editor stays
interactive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven tests covering app CRUD via the dashboard: create with
slug auto-derive, settings rename, delete with phrase-confirmation
modal, historical-slug takeover via the create form, plus adversarial
inputs (slug normalization, XSS in name/description, oversized name).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight tests covering the login form, layout-level redirects, logout,
and the obvious adversarial inputs (XSS in username, empty submit,
password field type, leaked tokens). All targeted at /admin/login and
the bounce-back behaviors implemented in +layout.svelte.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Milestone A of the frontend test plan. Sets up the test rig — config,
globalSetup that probes the backend and seeds an admin session into
storageState, lightweight fixtures, and a 3-test smoke spec — without
yet covering any user journeys (those land in Milestone B).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `membership_makes_app_appear_in_members_app_list` previously seeded
the membership via the repo helper; switch to the public POST
endpoint so the test actually exercises the full HTTP round-trip
the dashboard depends on.
- Add `add_member_with_missing_user_id_is_rejected` to pin the
Axum-JsonRejection 4xx contract on malformed POST bodies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apps_api.rs and app_members_api.rs each grew a near-identical local
`resolve_app` that parses an id-or-slug param and translates None into
their own AppNotFound variant. Promote the lookup half to
`app_repo::resolve_app` (returns `Result<Option<AppLookup>, ...>`) and
let callers handle the None → not-found mapping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A member-with-app_admin who removes their own membership keeps a now-
broken Members tab open until reload — `myRole` is only computed once
in `loadApp`, and the next `/apps/{slug}` fetch would 403 anyway.
After the DELETE succeeds, if the removed user is the caller, navigate
back to /apps instead of refreshing the local member list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous handlers did `find()` then `upsert()` in two round-trips:
- POST: two concurrent grants both pass the duplicate check; the
second `upsert` silently rewrites the role instead of returning
409, weakening the "409 on duplicate" contract under load.
- PATCH: a concurrent DELETE between `find` and `upsert` makes PATCH
silently re-create a row instead of returning 404, weakening the
"404 if no existing membership" contract.
Adds two repo primitives that fold the check into the write:
- `try_insert` — `INSERT ... ON CONFLICT DO NOTHING RETURNING`; None
return ⇒ already exists ⇒ 409.
- `update_role` — `UPDATE ... WHERE app_id AND user_id RETURNING`;
None return ⇒ no row ⇒ 404.
Handlers use these directly; existing `upsert` stays for test helpers
that genuinely want upsert semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new "App Member Management Endpoints" subsection covering the
shipped CRUD surface, the `my_role` field on the app lookup response,
and the no-last-app-admin-guard decision (with the corrected rationale
that owners — not admins — are what makes orphaning impossible).
Also updates the deferred-surfaces line so it stops claiming dashboard
member management is still curl-only, and bumps the Last Updated header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new "Members" tab is rendered between Domains and Settings for
callers whose `my_role` on the app is `app_admin` (owners always;
explicit member-app_admins; admins do not see it — they're only
implicit editors and can't manage memberships).
The tab lets the caller:
- See every explicit member of the app with username, email, instance-
role chip, app-role chip, and joined date. Inactive users render
greyed-out so admins know the row exists.
- Pick a `member`-instance user from a dropdown and grant viewer /
editor / app_admin access. The dropdown is populated from
`/admin/admins` filtered to active members not already on the app.
- Promote / demote / remove existing members via the shared
`ActionMenu` kebab. Removal goes through `ConfirmModal`.
Member-with-app_admin callers see a disabled add form with an
explanatory message — they have authority to manage memberships but
can't browse the user directory (gated on `InstanceManageUsers`),
which is a known phase-3.5 caveat to revisit in a follow-up.
Also extends `RoleChip` with an `appRole` prop and palette for app
roles, and adds an `appMembers` namespace to api.ts mirroring the
`domains` shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 16 integration tests against a real Postgres covering the new
/api/v1/admin/apps/{id_or_slug}/members surface:
- list / add / patch / remove against an explicit member row
- 409 on duplicate, 422 on inactive target, 422 on owner/admin target
- 404 on PATCH without an existing row; 204 idempotent DELETE
- viewer-as-bob receives 403 on every mutating verb
- both slug and UUID paths resolve to the same body
- bob-with-app_admin can manage the member list, including removing
himself (load-bearing for the no-last-app-admin-guard decision)
- granting a `member` user a viewer membership makes the app appear
in their `GET /admin/apps` list (was empty before)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /api/v1/admin/apps/{id_or_slug}/members[/{user_id}]:
- GET list members (joined with admin_users via list_for_app_enriched)
- POST grant membership — 201 with enriched DTO
409 on duplicate (promotions go through PATCH on purpose so
the UI can surface "already a member" cleanly)
422 if the target user is deactivated
422 if the target's instance_role isn't `member` — owners and
admins already have implicit authority, so an explicit row
would be dead weight
- PATCH change role — 200 with enriched DTO
404 if no existing membership (use POST to create)
- DELETE remove — 204, idempotent (matches the repo's `remove`
contract; 204 also when the row never existed)
All four gated on `Capability::AppAdmin(app_id)`. Editors and viewers
get 403 from list and never see the dashboard's Members tab.
No last-app-admin guard: owners implicitly satisfy AppAdmin via
`role_grants`, so removing the last explicit app_admin row cannot
permanently orphan an app — an owner can always re-issue grants.
Wires through picloud/src/lib.rs by splitting the Postgres app_members
repo Arc into two trait views (AppMembersRepository for CRUD, AuthzRepo
for the existing capability lookups) without re-instantiating against
the pool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `AppMembershipDetail` (membership row + joined username, email,
instance_role, is_active) and `list_for_app_enriched` on
`AppMembersRepository`. The Postgres impl does a single JOIN on
admin_users ordered by username, so the upcoming `GET
/apps/{id}/members` handler can render its table without an N+1 fetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/v1/admin/apps/{id_or_slug} now returns an `AppRole`-typed
`my_role` alongside the existing app fields, computed server-side from
the Principal: `Owner → app_admin` and `Admin → editor` (both
implicit per blueprint §11.6), `Member → app_members.role` (looked up
via the existing `AuthzRepo::membership` already in `AppsState`).
The dashboard uses this single field to decide whether to render
admin-only surfaces (Members tab, etc.) instead of duplicating the
implicit-grant rules on the client side — keeps API and UI gate logic
identical with one round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the inline row-action buttons on the Users page with the new
shared ActionMenu kebab. Drops the redundant `is_active` toggle from the
edit form (Activate/Deactivate already lives in the kebab).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The backend's ApiKeyDto.prefix is just the 8-char public head
(e.g. "PKXPCPH3"); the actual token the user pastes into their
CLI is "pic_PKXPCPH3…". Display the full visible identifier so
operators can match a row against the token in their notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /admins create/patch endpoints now plumb email through to the
repo so the dashboard's invite + edit forms aren't silently dropping
it on the floor. Discovered during smoke testing — the database
column existed and was exposed in the response DTO, but neither
the request DTO nor the repo's create() accepted it.
CreateAdminRequest gains optional email; PatchAdminRequest gains
email with JSON Merge Patch semantics:
absent → don't change
null → clear (write NULL)
"<string>" → set to that value
The tri-state needs Option<Option<String>> with a tiny custom
deserializer; serde collapses absent and null otherwise.
normalize_email() trims, treats blanks as None, and rejects
obviously bogus values (no '@', >254 chars) with a 422. Real
email verification is a future concern.
Repo trait gains an email parameter on create() and a new
update_email() method. The unique-violation branch in create now
inspects constraint() to distinguish duplicate username from
duplicate email.
Integration test exercises create-with-email, PATCH null clears,
PATCH value sets, PATCH without email key no-ops on email.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
clippy::map_unwrap_or — drop the map().unwrap_or(false) for the
flatter is_some_and(Value::is_null).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/admin/users is a strict superset of the pre-3.5 /admin/admins
page (adds role chip, email column, search, role-aware affordance
hiding, and the password-reveal flow), so the old page would only
split traffic and confuse muscle memory.
Also drops the AdminUserRecord type alias kept in place to ease
the transition.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/admin/users is the owner+admin surface for managing the platform's
user list. Members get bounced to /profile?denied=users.
Invite generates a random 16-char password client-side, POSTs the
new user, and surfaces the cleartext exactly once in a yellow-
bordered reveal modal with a Copy button and an "I've shared it"
acknowledgement gate. Owner role is intentionally not in the create
form — promote via Edit after creation, matching the backend's
deliberate-step comment.
Edit handles username, email, role (with affordance hiding: admins
see admin/member only), is_active toggle, and a separate "Reset
password" button that re-uses the same reveal flow. Delete uses
ConfirmModal with confirmPhrase=username and explains the
last-owner/last-admin 422s up front.
Username + email validated client-side against the same patterns
the backend enforces so the form fails fast rather than always
on the round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/admin/profile is the per-principal page available to every
authenticated user (owner, admin, member). Shows the caller's
identity (username, role chip, email, id) plus a full API-key
list/mint/revoke surface.
Minting reveals the raw token exactly once in a yellow-bordered
panel with a Copy button and an "I've saved it" acknowledgement
gate before the Done button enables, matching the spec's one-shot
secret-display pattern.
Live mirrors the backend bound-key guard: picking an app from the
binding dropdown drops any instance:* scopes from the selection and
greys out their checkboxes with a tooltip, so submit never hits a
422 on that case.
Also surfaces a one-shot info banner when /admin/users redirects a
member here with ?denied=users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The header nav now shows a Users link only for owners/admins, and
the username block becomes a profile-link chip rendering the role
pill next to the name. Both react to the currentUser store, so they
update on login without an extra fetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>