22 Commits

Author SHA1 Message Date
MechaCat02
ec3c768262 test(dashboard): add full-stack integration specs
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>
2026-05-28 07:56:24 +02:00
MechaCat02
3e72ddde78 test(dashboard): stabilize the e2e suite under parallel runs
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>
2026-05-28 07:44:07 +02:00
MechaCat02
cd20ffb580 test(dashboard): add e2e cross-cutting security spec (B8)
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>
2026-05-28 07:43:51 +02:00
MechaCat02
cddd479fd2 test(dashboard): add e2e profile + API keys spec (B7)
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>
2026-05-28 07:31:58 +02:00
MechaCat02
8bbcdd86aa test(dashboard): add e2e instance users spec (B6)
Eight tests covering the Users admin page: invite happy path (form
→ reveal modal → ack-gated dismiss → row in table), live username
validation, search filter, deactivate/reactivate, delete with phrase
modal, member-role redirect to /profile?denied=users, plus
adversarial inputs (too-short username, script-tag email).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:28:04 +02:00
MechaCat02
2d56e42699 test(dashboard): add e2e app members spec (B5)
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>
2026-05-28 07:23:01 +02:00
MechaCat02
f9d9ed8cb4 test(dashboard): add e2e routing spec (B4)
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>
2026-05-28 07:18:01 +02:00
MechaCat02
c17f8a5bd9 test(dashboard): add e2e script CRUD + editor spec (B3)
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>
2026-05-28 07:14:09 +02:00
MechaCat02
7198fb4d0e test(dashboard): add e2e apps lifecycle spec (B2)
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>
2026-05-28 07:10:47 +02:00
MechaCat02
029a4a199f test(dashboard): add e2e auth & navigation spec (B1)
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>
2026-05-28 07:06:13 +02:00
MechaCat02
74f7b3b631 test(dashboard): add Playwright e2e scaffolding with smoke spec
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>
2026-05-28 07:03:44 +02:00
MechaCat02
e6fc6e6a0e test(picloud): close two app_members test gaps
- `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>
2026-05-27 22:00:28 +02:00
MechaCat02
66b84abf6d refactor(manager-core): share resolve_app helper across handlers
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>
2026-05-27 22:00:21 +02:00
MechaCat02
a9fc838577 fix(dashboard): redirect after a member removes themselves
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>
2026-05-27 22:00:13 +02:00
MechaCat02
2948875a96 fix(api): make app_members POST and PATCH atomic
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>
2026-05-27 22:00:04 +02:00
MechaCat02
b7175cc581 chore: rustfmt fixups for app_members files
Trailing-comma format! cleanup from `cargo fmt --all`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:39:25 +02:00
MechaCat02
d40ebf65a2 docs(blueprint): document app members CRUD endpoints in §11.6
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>
2026-05-27 21:38:36 +02:00
MechaCat02
816a13b920 feat(dashboard): Members tab on the app detail page
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>
2026-05-27 21:37:36 +02:00
MechaCat02
248571dcde test(picloud): authz coverage for app members CRUD
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>
2026-05-27 21:33:59 +02:00
MechaCat02
85bbabcbdf feat(api): app members CRUD endpoints
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>
2026-05-27 21:31:08 +02:00
MechaCat02
1314420fca feat(repo): join app_members with admin_users via list_for_app_enriched
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>
2026-05-27 21:27:02 +02:00
MechaCat02
33697a2766 feat(api): expose caller's effective app role via my_role
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>
2026-05-27 21:25:23 +02:00
32 changed files with 3509 additions and 81 deletions

11
.gitignore vendored
View File

@@ -30,6 +30,17 @@ config.local.toml
/dashboard/build
/dashboard/.env
# Dashboard — Playwright E2E
/dashboard/tests/e2e/.auth
/dashboard/tests/e2e/.results
/dashboard/playwright-report
/dashboard/test-results
/dashboard/.playwright
# When playwright is invoked from the repo root by accident, these
# also land here.
/playwright-report
/test-results
# Caddy
/caddy/data
/caddy/config

View File

@@ -0,0 +1,331 @@
//! `/api/v1/admin/apps/{id_or_slug}/members/*` — CRUD over the
//! `app_members` table (blueprint §11.6).
//!
//! Every endpoint is gated on `Capability::AppAdmin(app_id)` after
//! resolving the app from `id_or_slug`. Editors and viewers receive
//! 403 from list and never see the dashboard's Members tab.
//!
//! POST is **non-idempotent on purpose**: a duplicate `(app_id,
//! user_id)` returns 409 rather than upsert-200, so the UI can show
//! "already a member — promote / demote them instead" cleanly. Role
//! changes go through PATCH.
//!
//! No last-app-admin guard: owners always implicitly satisfy
//! `Capability::AppAdmin(_)` (authz::role_grants), so removing the
//! final explicit `app_admin` membership cannot orphan an app.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, patch};
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppRole, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
use crate::app_members_repo::{
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError;
#[derive(Clone)]
pub struct AppMembersState {
pub apps: Arc<dyn AppRepository>,
pub users: Arc<dyn AdminUserRepository>,
pub members: Arc<dyn AppMembersRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn app_members_router(state: AppMembersState) -> Router {
Router::new()
.route(
"/apps/{id_or_slug}/members",
get(list_members).post(grant_member),
)
.route(
"/apps/{id_or_slug}/members/{user_id}",
patch(patch_member).delete(remove_member),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub struct AppMemberDto {
pub user_id: AdminUserId,
pub username: String,
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
impl From<AppMembershipDetail> for AppMemberDto {
fn from(d: AppMembershipDetail) -> Self {
Self {
user_id: d.user_id,
username: d.username,
email: d.email,
instance_role: d.instance_role,
is_active: d.is_active,
role: d.role,
created_at: d.created_at,
}
}
}
/// Compose a DTO from an `AdminUserRow` (fetched for validation) and
/// the `AppMembershipRow` returned by `upsert`. Saves a re-fetch on
/// POST/PATCH at the cost of trusting the two inputs reference the
/// same user_id — caller's responsibility.
fn compose_dto(user: AdminUserRow, membership: AppMembershipRow) -> AppMemberDto {
AppMemberDto {
user_id: user.id,
username: user.username,
email: user.email,
instance_role: user.instance_role,
is_active: user.is_active,
role: membership.role,
created_at: membership.created_at,
}
}
#[derive(Debug, Deserialize)]
pub struct GrantMemberRequest {
pub user_id: AdminUserId,
pub role: AppRole,
}
#[derive(Debug, Deserialize)]
pub struct PatchMemberRequest {
pub role: AppRole,
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn list_members(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<AppMemberDto>>, AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
let rows = s.members.list_for_app_enriched(app.id).await?;
Ok(Json(rows.into_iter().map(Into::into).collect()))
}
async fn grant_member(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<GrantMemberRequest>,
) -> Result<(StatusCode, Json<AppMemberDto>), AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
let user = s
.users
.get(input.user_id)
.await?
.ok_or(AppMembersApiError::UserNotFound(input.user_id))?;
validate_grant_target(&user)?;
// Atomic insert — if a row already exists, returns None and we 409.
// Avoids the find-then-upsert race where two concurrent POSTs would
// both pass the existence check and the second `upsert` would
// silently rewrite the role.
let row = s
.members
.try_insert(app.id, user.id, input.role)
.await?
.ok_or_else(|| AppMembersApiError::AlreadyMember {
username: user.username.clone(),
})?;
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
}
async fn patch_member(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
Json(input): Json<PatchMemberRequest>,
) -> Result<Json<AppMemberDto>, AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
let user_id = AdminUserId::from(user_id);
let user = s
.users
.get(user_id)
.await?
.ok_or(AppMembersApiError::UserNotFound(user_id))?;
// Atomic update — returns None if no row exists, so 404 is decided
// by the same statement that does the write. Eliminates the
// find-then-upsert race where a concurrent DELETE between the two
// calls would let PATCH silently re-create the row.
let row = s
.members
.update_role(app.id, user_id, input.role)
.await?
.ok_or(AppMembersApiError::MembershipNotFound)?;
Ok(Json(compose_dto(user, row)))
}
async fn remove_member(
State(s): State<AppMembersState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppMembersApiError> {
let app = resolve_app(&*s.apps, &id_or_slug).await?;
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
s.members.remove(app.id, AdminUserId::from(user_id)).await?;
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Validation + helpers
// ----------------------------------------------------------------------------
fn validate_grant_target(user: &AdminUserRow) -> Result<(), AppMembersApiError> {
if !user.is_active {
return Err(AppMembersApiError::TargetInactive {
username: user.username.clone(),
});
}
if user.instance_role != InstanceRole::Member {
return Err(AppMembersApiError::TargetNotMember {
username: user.username.clone(),
instance_role: user.instance_role,
});
}
Ok(())
}
async fn resolve_app(
apps: &dyn AppRepository,
ident: &str,
) -> Result<picloud_shared::App, AppMembersApiError> {
crate::app_repo::resolve_app(apps, ident)
.await?
.map(|l| l.app)
.ok_or_else(|| AppMembersApiError::AppNotFound(ident.to_string()))
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum AppMembersApiError {
#[error("app not found: {0}")]
AppNotFound(String),
#[error("user not found: {0}")]
UserNotFound(AdminUserId),
#[error("no membership exists for this user on this app")]
MembershipNotFound,
#[error("{username} is already a member of this app — use PATCH to change their role")]
AlreadyMember { username: String },
#[error("{username} is deactivated and cannot be added as a member")]
TargetInactive { username: String },
#[error(
"{username} has instance_role {instance_role:?} and already has implicit access \
on every app — no explicit membership needed"
)]
TargetNotMember {
username: String,
instance_role: InstanceRole,
},
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")]
Members(#[from] AppMembersRepositoryError),
#[error("user repository error: {0}")]
Users(#[from] AdminUserRepositoryError),
#[error("repository error: {0}")]
Apps(#[from] ScriptRepositoryError),
}
impl From<AuthzDenied> for AppMembersApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for AppMembersApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_)
| Self::UserNotFound(_)
| Self::MembershipNotFound
| Self::Apps(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::AlreadyMember { .. } | Self::Apps(ScriptRepositoryError::Conflict(_)) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "app members authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Members(e) => {
tracing::error!(error = %e, "app members repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Users(e) => {
tracing::error!(error = %e, "admin users repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Apps(ScriptRepositoryError::Db(e)) => {
tracing::error!(error = %e, "apps repo error in app_members");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -8,7 +8,7 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppId, AppRole};
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
use sqlx::PgPool;
use crate::authz::{AuthzError, AuthzRepo};
@@ -36,6 +36,20 @@ pub struct AppMembershipRow {
pub created_at: DateTime<Utc>,
}
/// `app_members` row joined with `admin_users` so the dashboard's
/// Members tab can render usernames / emails / status without an N+1
/// fetch per row. Drives `GET /apps/{id}/members`.
#[derive(Debug, Clone)]
pub struct AppMembershipDetail {
pub user_id: AdminUserId,
pub username: String,
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
#[async_trait]
pub trait AppMembersRepository: Send + Sync {
/// Single (user, app) lookup. Returns `None` for non-members and
@@ -55,6 +69,27 @@ pub trait AppMembersRepository: Send + Sync {
role: AppRole,
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
/// Atomic insert. Returns `Some(row)` on success, `None` if a
/// membership already exists. Lets the HTTP handler return 409
/// without a separate `find` round-trip (no TOCTOU between check
/// and insert).
async fn try_insert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
/// Atomic role update. Returns `Some(row)` on success, `None` if no
/// membership row exists. Lets PATCH return 404 without a separate
/// `find` round-trip (no TOCTOU between check and update).
async fn update_role(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
/// Remove a membership. No-op (Ok) when the row doesn't exist —
/// the user wasn't a member, which is the desired post-condition.
async fn remove(
@@ -78,6 +113,14 @@ pub trait AppMembersRepository: Send + Sync {
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
/// Like `list_for_app` but joined with `admin_users` so the
/// dashboard can render member rows in one round-trip. Ordered by
/// username for a stable list.
async fn list_for_app_enriched(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
}
pub struct PostgresAppMembersRepository {
@@ -143,6 +186,45 @@ impl AppMembersRepository for PostgresAppMembersRepository {
Ok(())
}
async fn try_insert(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
let row = sqlx::query_as::<_, AppMembershipRecord>(
"INSERT INTO app_members (app_id, user_id, role) \
VALUES ($1, $2, $3) \
ON CONFLICT (app_id, user_id) DO NOTHING \
RETURNING app_id, user_id, role, created_at",
)
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.bind(role.as_str())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn update_role(
&self,
app_id: AppId,
user_id: AdminUserId,
role: AppRole,
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
let row = sqlx::query_as::<_, AppMembershipRecord>(
"UPDATE app_members SET role = $1 \
WHERE app_id = $2 AND user_id = $3 \
RETURNING app_id, user_id, role, created_at",
)
.bind(role.as_str())
.bind(app_id.into_inner())
.bind(user_id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
async fn list_for_user(
&self,
user_id: AdminUserId,
@@ -172,6 +254,24 @@ impl AppMembersRepository for PostgresAppMembersRepository {
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn list_for_app_enriched(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError> {
let rows = sqlx::query_as::<_, AppMembershipDetailRecord>(
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
am.role, am.created_at \
FROM app_members am \
JOIN admin_users au ON au.id = am.user_id \
WHERE am.app_id = $1 \
ORDER BY au.username",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
}
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
@@ -210,3 +310,31 @@ impl TryFrom<AppMembershipRecord> for AppMembershipRow {
})
}
}
#[derive(sqlx::FromRow)]
struct AppMembershipDetailRecord {
id: uuid::Uuid,
username: String,
email: Option<String>,
instance_role: String,
is_active: bool,
role: String,
created_at: DateTime<Utc>,
}
impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
type Error = AppMembersRepositoryError;
fn try_from(r: AppMembershipDetailRecord) -> Result<Self, Self::Error> {
Ok(Self {
user_id: r.id.into(),
username: r.username,
email: r.email,
instance_role: InstanceRole::from_db_str(&r.instance_role)
.ok_or(AppMembersRepositoryError::InvalidRole(r.instance_role))?,
is_active: r.is_active,
role: AppRole::from_db_str(&r.role)
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
created_at: r.created_at,
})
}
}

View File

@@ -8,6 +8,7 @@
use async_trait::async_trait;
use picloud_shared::{AdminUserId, App, AppId};
use sqlx::PgPool;
use uuid::Uuid;
use crate::repo::ScriptRepositoryError;
@@ -20,6 +21,32 @@ pub struct AppLookup {
pub redirected: bool,
}
/// Resolve a free-form path param (UUID *or* slug *or* historical slug)
/// to an `AppLookup`. UUID lookups never set `redirected`; slug lookups
/// fall through to `app_slug_history` and set `redirected: true` when
/// they hit it.
///
/// Returns `Ok(None)` when nothing matches — callers map that to their
/// own not-found error variant.
///
/// # Errors
/// Propagates any underlying repository error.
pub async fn resolve_app(
apps: &dyn AppRepository,
ident: &str,
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
if let Ok(uuid) = ident.parse::<Uuid>() {
return Ok(apps
.get_by_id(AppId::from(uuid))
.await?
.map(|app| AppLookup {
app,
redirected: false,
}));
}
apps.get_by_slug_or_history(ident).await
}
#[async_trait]
pub trait AppRepository: Send + Sync {
/// Every app on the instance. For owner/admin callers — `member`

View File

@@ -17,14 +17,14 @@ use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Router};
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
use picloud_shared::{App, AppDomain, AppId, InstanceRole, Principal};
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError;
use crate::route_repo::RouteRepository;
@@ -141,6 +141,12 @@ pub struct AppLookupResponse {
/// at the live slug so dashboards can redirect.
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect_to: Option<String>,
/// The caller's role on this app, used by the dashboard to decide
/// whether to render admin-only surfaces (Members tab, settings).
/// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit
/// per blueprint §11.6); `Member` carries its explicit
/// `app_members.role`.
pub my_role: Option<AppRole>,
}
// ----------------------------------------------------------------------------
@@ -209,12 +215,31 @@ async fn get_app(
} else {
None
};
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
Ok(Json(AppLookupResponse {
app: lookup.app,
redirect_to,
my_role,
}))
}
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
/// the implicit-grant logic in `authz::role_grants` but returns the
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
/// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor`
/// everywhere; `Member` consults `app_members`.
async fn compute_my_role(
authz: &dyn AuthzRepo,
principal: &Principal,
app_id: AppId,
) -> Result<Option<AppRole>, AppsApiError> {
match principal.instance_role {
InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)),
InstanceRole::Admin => Ok(Some(AppRole::Editor)),
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
}
}
async fn patch_app(
State(s): State<AppsState>,
Extension(principal): Extension<Principal>,
@@ -429,16 +454,7 @@ async fn resolve_app(
apps: &dyn AppRepository,
ident: &str,
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
if let Ok(uuid) = ident.parse::<Uuid>() {
if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? {
return Ok(crate::app_repo::AppLookup {
app,
redirected: false,
});
}
return Err(AppsApiError::AppNotFound(ident.to_string()));
}
apps.get_by_slug_or_history(ident)
crate::app_repo::resolve_app(apps, ident)
.await?
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
}
@@ -546,6 +562,12 @@ impl From<AuthzDenied> for AppsApiError {
}
}
impl From<AuthzError> for AppsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl IntoResponse for AppsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {

View File

@@ -12,6 +12,7 @@ pub mod api_key_repo;
pub mod api_keys_api;
pub mod app_bootstrap;
pub mod app_domain_repo;
pub mod app_members_api;
pub mod app_members_repo;
pub mod app_repo;
pub mod apps_api;
@@ -45,10 +46,12 @@ pub use api_key_repo::{
pub use api_keys_api::{api_keys_router, ApiKeysState};
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
pub use app_members_api::{app_members_router, AppMembersApiError, AppMembersState};
pub use app_members_repo::{
AppMembersRepository, AppMembersRepositoryError, AppMembershipRow, PostgresAppMembersRepository,
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
PostgresAppMembersRepository,
};
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository};
pub use apps_api::{apps_router, AppsState};
pub use auth_api::auth_router;
pub use auth_bootstrap::{

View File

@@ -10,14 +10,15 @@ use axum::middleware::from_fn_with_state;
use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
AppDomainRepository, AppRepository, AppsState, AuthState, AuthzRepo,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
auth_router, compile_routes, migrations, require_authenticated, route_admin_router,
AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository,
ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository,
AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
RouteRepository, SandboxCeiling,
};
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{
@@ -79,6 +80,7 @@ fn read_session_ttl() -> Duration {
/// the `require_admin` middleware. The data plane
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
/// `/version`) stays open — it's the public ingress for user scripts.
#[allow(clippy::too_many_lines)]
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let engine = Arc::new(Engine::new(Limits::default()));
@@ -89,9 +91,13 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> =
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
// Authz: app_members repo doubles as the AuthzRepo impl for the
// per-handler capability checks introduced in Phase 3.5.
let authz: Arc<dyn AuthzRepo> = Arc::new(PostgresAppMembersRepository::new(pool));
// The Postgres app_members repo implements both `AppMembersRepository`
// (CRUD over the table) and `AuthzRepo` (single-row membership lookup
// for capability checks). Construct it once and clone the Arc into
// both trait views — same allocation, two vtables.
let members_concrete = Arc::new(PostgresAppMembersRepository::new(pool));
let members: Arc<dyn AppMembersRepository> = members_concrete.clone();
let authz: Arc<dyn AuthzRepo> = members_concrete;
// Compile the routes table once at startup; admin writes refresh it.
let route_table = Arc::new(RouteTable::new());
@@ -159,9 +165,15 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
ttl: auth.ttl,
};
let admins_state = AdminsState {
users: auth.users,
users: auth.users.clone(),
sessions: auth.sessions,
keys: auth.keys.clone(),
authz: authz.clone(),
};
let app_members_state = AppMembersState {
apps: apps_state.apps.clone(),
users: auth.users,
members,
authz,
};
let api_keys_state = ApiKeysState { keys: auth.keys };
@@ -177,6 +189,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
.merge(route_admin_router(route_admin))
.merge(admins_router(admins_state))
.merge(apps_router(apps_state))
.merge(app_members_router(app_members_state))
.merge(api_keys_router(api_keys_state))
.layer(from_fn_with_state(
auth_state.clone(),

View File

@@ -160,6 +160,72 @@ async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_te
.await
}
// --- app members helpers ----------------------------------------------------
async fn list_members(
server: &TestServer,
token: &str,
app_ident: &str,
) -> axum_test::TestResponse {
server
.get(&format!("/api/v1/admin/apps/{app_ident}/members"))
.add_header("authorization", format!("Bearer {token}"))
.await
}
async fn add_member(
server: &TestServer,
token: &str,
app_ident: &str,
user_id: AdminUserId,
role: AppRole,
) -> axum_test::TestResponse {
server
.post(&format!("/api/v1/admin/apps/{app_ident}/members"))
.add_header("authorization", format!("Bearer {token}"))
.json(&json!({ "user_id": user_id, "role": role.as_str() }))
.await
}
async fn patch_member_role(
server: &TestServer,
token: &str,
app_ident: &str,
user_id: AdminUserId,
role: AppRole,
) -> axum_test::TestResponse {
server
.patch(&format!("/api/v1/admin/apps/{app_ident}/members/{user_id}",))
.add_header("authorization", format!("Bearer {token}"))
.json(&json!({ "role": role.as_str() }))
.await
}
async fn remove_member(
server: &TestServer,
token: &str,
app_ident: &str,
user_id: AdminUserId,
) -> axum_test::TestResponse {
server
.delete(&format!("/api/v1/admin/apps/{app_ident}/members/{user_id}",))
.add_header("authorization", format!("Bearer {token}"))
.await
}
/// Direct-DB inactive-user seed — the create-then-deactivate dance
/// through the API is more ceremony than the test needs.
async fn seed_inactive_user(pool: &PgPool, username: &str, password: &str) -> AdminUserId {
let repo = PostgresAdminUserRepository::new(pool.clone());
let hash = hash_password(password).expect("hash");
let row = repo
.create(username, &hash, InstanceRole::Member, None)
.await
.expect("seed user");
repo.set_active(row.id, false).await.expect("deactivate");
row.id
}
// ----------------------------------------------------------------------------
// 1. Bootstrap admin → owner
// ----------------------------------------------------------------------------
@@ -645,3 +711,389 @@ async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) {
.expect("count");
assert_eq!(remaining, 1, "one other owner should remain (owner2)");
}
// ----------------------------------------------------------------------------
// 12. `my_role` on GET /apps/{id_or_slug} reflects the caller's effective role
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn my_role_field_matches_caller_role(pool: PgPool) {
let s = boot(pool).await;
// Owner → implicit app_admin everywhere.
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let r = s
.server
.get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {owner_token}"))
.await;
r.assert_status_ok();
assert_eq!(
r.json::<Value>()["my_role"].as_str(),
Some("app_admin"),
"owner reports app_admin"
);
// Admin → implicit editor everywhere.
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
let admin_token = login_token(&s.server, "alice", "alice-pw").await;
let r = s
.server
.get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {admin_token}"))
.await;
r.assert_status_ok();
assert_eq!(
r.json::<Value>()["my_role"].as_str(),
Some("editor"),
"admin reports editor"
);
// Member with explicit `viewer` membership → viewer.
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
let r = s
.server
.get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {bob_token}"))
.await;
r.assert_status_ok();
assert_eq!(
r.json::<Value>()["my_role"].as_str(),
Some("viewer"),
"member with viewer row reports viewer"
);
}
// ----------------------------------------------------------------------------
// 13. App members CRUD — `/api/v1/admin/apps/{id_or_slug}/members[/{user_id}]`
// ----------------------------------------------------------------------------
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_members_includes_seeded_member(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
let r = list_members(&s.server, &owner_token, "default").await;
r.assert_status_ok();
let rows = r.json::<Vec<Value>>();
let bob_row = rows
.iter()
.find(|v| v["username"] == "bob")
.expect("bob in list");
assert_eq!(bob_row["role"], "viewer");
assert_eq!(bob_row["instance_role"], "member");
assert_eq!(bob_row["is_active"], true);
assert!(bob_row["created_at"].is_string(), "carries created_at");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_members_requires_app_admin(pool: PgPool) {
let s = boot(pool).await;
// Bob has explicit editor on default app — enough to read scripts,
// not enough to see the member list.
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await;
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
let r = list_members(&s.server, &bob_token, "default").await;
r.assert_status(axum::http::StatusCode::FORBIDDEN);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_creates_row(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
r.assert_status(axum::http::StatusCode::CREATED);
let body = r.json::<Value>();
assert_eq!(body["username"], "bob");
assert_eq!(body["role"], "viewer");
assert_eq!(body["instance_role"], "member");
// Visible on subsequent list.
let rows = list_members(&s.server, &owner_token, "default")
.await
.json::<Vec<Value>>();
assert!(rows.iter().any(|v| v["username"] == "bob"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_duplicate_returns_409(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
r.assert_status(axum::http::StatusCode::CONFLICT);
let err = r.json::<Value>()["error"]
.as_str()
.expect("error message")
.to_string();
assert!(err.contains("already a member"), "got: {err}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_inactive_user_returns_422(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let bob = seed_inactive_user(&s.pool, "bob", "bob-pw").await;
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
let err = r.json::<Value>()["error"]
.as_str()
.expect("error message")
.to_string();
assert!(err.contains("deactivated"), "got: {err}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_admin_target_returns_422(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let alice = seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
let r = add_member(&s.server, &owner_token, "default", alice, AppRole::Viewer).await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
let err = r.json::<Value>()["error"]
.as_str()
.expect("error message")
.to_string();
assert!(err.contains("implicit access"), "got: {err}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_owner_target_returns_422(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let other_owner = seed_user(&s.pool, "owner2", "ow2-pw", InstanceRole::Owner).await;
let r = add_member(
&s.server,
&owner_token,
"default",
other_owner,
AppRole::Viewer,
)
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn patch_member_promotes_role(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
r.assert_status_ok();
assert_eq!(r.json::<Value>()["role"], "editor");
// Editor can now create a script (capability promotion observable
// end-to-end, not just via the role string).
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
create_script_via_api(&s.server, &bob_token, s.default_app, "bob-script").await;
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn patch_member_without_existing_returns_404(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
// No grant yet — PATCH must 404.
let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
r.assert_status(axum::http::StatusCode::NOT_FOUND);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn patch_member_same_role_is_idempotent(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
r.assert_status_ok();
assert_eq!(r.json::<Value>()["role"], "viewer");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn delete_member_removes_row(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
let r = remove_member(&s.server, &owner_token, "default", bob).await;
r.assert_status(axum::http::StatusCode::NO_CONTENT);
let rows = list_members(&s.server, &owner_token, "default")
.await
.json::<Vec<Value>>();
assert!(rows.iter().all(|v| v["username"] != "bob"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn delete_member_missing_returns_204(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
// No grant ever happened — delete is idempotent.
let r = remove_member(&s.server, &owner_token, "default", bob).await;
r.assert_status(axum::http::StatusCode::NO_CONTENT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn mutating_endpoints_require_app_admin(pool: PgPool) {
let s = boot(pool).await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
let target = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
let r = add_member(&s.server, &bob_token, "default", target, AppRole::Viewer).await;
r.assert_status(axum::http::StatusCode::FORBIDDEN);
let r = patch_member_role(&s.server, &bob_token, "default", bob, AppRole::Editor).await;
r.assert_status(axum::http::StatusCode::FORBIDDEN);
let r = remove_member(&s.server, &bob_token, "default", bob).await;
r.assert_status(axum::http::StatusCode::FORBIDDEN);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn members_endpoint_resolves_by_id_or_slug(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let by_slug = list_members(&s.server, &owner_token, "default").await;
by_slug.assert_status_ok();
let by_id = list_members(&s.server, &owner_token, &s.default_app.to_string()).await;
by_id.assert_status_ok();
assert_eq!(
by_slug.json::<Value>(),
by_id.json::<Value>(),
"id and slug return identical bodies",
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn member_app_admin_can_manage_members(pool: PgPool) {
let s = boot(pool).await;
// Bob is a member with explicit app_admin role on default.
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
grant_membership(&s.pool, bob, s.default_app, AppRole::AppAdmin).await;
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
// Bob can list members.
let r = list_members(&s.server, &bob_token, "default").await;
r.assert_status_ok();
// Bob can add carol as viewer.
let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
let r = add_member(&s.server, &bob_token, "default", carol, AppRole::Viewer).await;
r.assert_status(axum::http::StatusCode::CREATED);
// Bob can promote carol to editor.
let r = patch_member_role(&s.server, &bob_token, "default", carol, AppRole::Editor).await;
r.assert_status_ok();
// Bob can remove carol.
let r = remove_member(&s.server, &bob_token, "default", carol).await;
r.assert_status(axum::http::StatusCode::NO_CONTENT);
// And bob can even remove himself — owner's implicit AppAdmin
// means the app isn't orphaned. This is the load-bearing test for
// the no-last-app-admin-guard decision.
let r = remove_member(&s.server, &bob_token, "default", bob).await;
r.assert_status(axum::http::StatusCode::NO_CONTENT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn membership_makes_app_appear_in_members_app_list(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
// Before grant: bob sees no apps.
let r = s
.server
.get("/api/v1/admin/apps")
.add_header("authorization", format!("Bearer {bob_token}"))
.await;
r.assert_status_ok();
assert!(
r.json::<Vec<Value>>().is_empty(),
"bob has no memberships → empty apps list"
);
// Grant via the public POST endpoint — exercises the full
// round-trip the dashboard goes through, not just the repo seam.
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
r.assert_status(axum::http::StatusCode::CREATED);
// After grant: bob sees the default app.
let r = s
.server
.get("/api/v1/admin/apps")
.add_header("authorization", format!("Bearer {bob_token}"))
.await;
r.assert_status_ok();
let apps = r.json::<Vec<Value>>();
assert_eq!(apps.len(), 1);
assert_eq!(apps[0]["slug"], "default");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn add_member_with_missing_user_id_is_rejected(pool: PgPool) {
let s = boot(pool).await;
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
// Body missing `user_id` — Axum's Json extractor produces a 4xx
// before our handler runs. Pinning the status to keep the contract
// honest if anyone ever swaps the extractor.
let r = s
.server
.post("/api/v1/admin/apps/default/members")
.add_header("authorization", format!("Bearer {owner_token}"))
.json(&json!({ "role": "viewer" }))
.await;
let status = r.status_code().as_u16();
assert!(
(400..500).contains(&status),
"malformed body should produce a 4xx, got {status}"
);
}

View File

@@ -2,3 +2,9 @@
build
node_modules
package-lock.json
# Playwright generated artifacts
playwright-report
test-results
tests/e2e/.auth
tests/e2e/.results

View File

@@ -20,6 +20,7 @@
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
@@ -885,6 +886,22 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -3010,6 +3027,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

View File

@@ -11,10 +11,14 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test": "vitest run"
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install --with-deps chromium"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",

View File

@@ -0,0 +1,51 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
// baseURL is the origin only — the SvelteKit dashboard is mounted at
// `/admin` (svelte.config.js paths.base), so tests use full paths like
// `/admin/login` rather than relying on baseURL path resolution.
const DASHBOARD_BASE = process.env.E2E_BASE_URL ?? `http://localhost:${DASHBOARD_PORT}`;
export default defineConfig({
testDir: './tests/e2e',
outputDir: './tests/e2e/.results',
fullyParallel: true,
forbidOnly: !!process.env.CI,
// Local: 1 retry to absorb dev-server warmup flakiness. CI: 2.
retries: process.env.CI ? 2 : 1,
// Cap at 4 workers locally to keep the shared Vite dev server
// from getting stampeded during cold-start compiles.
workers: process.env.CI ? 2 : 4,
reporter: process.env.CI ? [['html'], ['github']] : 'html',
globalSetup: './tests/e2e/global-setup.ts',
expect: { timeout: 5_000 },
use: {
baseURL: DASHBOARD_BASE,
actionTimeout: 10_000,
navigationTimeout: 30_000,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: path.join(__dirname, 'tests/e2e/.auth/admin.json')
}
}
],
webServer: {
command: 'npm run dev',
url: `http://localhost:${DASHBOARD_PORT}/admin/`,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
stderr: 'pipe',
timeout: 60_000
}
});

View File

@@ -1,15 +1,24 @@
<script lang="ts">
import type { InstanceRole } from '$lib/auth';
import type { AppRole } from '$lib/api';
interface Props {
role: InstanceRole;
role?: InstanceRole;
appRole?: AppRole;
size?: 'sm' | 'md';
}
let { role, size = 'md' }: Props = $props();
let { role, appRole, size = 'md' }: Props = $props();
// Display label: app roles read better with a space ("app admin")
// than their wire form ("app_admin").
const label = $derived(
appRole ? appRole.replace('_', ' ') : (role ?? '')
);
const cls = $derived(appRole ? `chip-${appRole}` : `chip-${role}`);
</script>
<span class="chip chip-{role}" class:sm={size === 'sm'}>{role}</span>
<span class="chip {cls}" class:sm={size === 'sm'}>{label}</span>
<style>
.chip {
@@ -42,4 +51,19 @@
color: #cbd5e1;
border-color: #334155;
}
.chip-app_admin {
background: #4c1d95;
color: #c4b5fd;
border-color: #6d28d9;
}
.chip-editor {
background: #1e3a8a;
color: #93c5fd;
border-color: #1d4ed8;
}
.chip-viewer {
background: #1f2937;
color: #9ca3af;
border-color: #374151;
}
</style>

View File

@@ -44,6 +44,8 @@ export interface App {
updated_at: string;
}
export type AppRole = 'app_admin' | 'editor' | 'viewer';
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
export interface AppDomain {
@@ -64,6 +66,11 @@ export interface AppLookupResponse {
updated_at: string;
/// Present only when the requested slug was a retired redirect.
redirect_to?: string;
/// The caller's role on this app — owners are implicit `app_admin`,
/// admins implicit `editor`, members carry their `app_members.role`.
/// `null` only when a member somehow reaches the endpoint without
/// a membership (the server normally 403s first).
my_role: AppRole | null;
}
export interface SlugCheckResponse {
@@ -289,6 +296,21 @@ export interface PatchAdminInput {
email?: string | null;
}
export interface AppMemberDto {
user_id: string;
username: string;
email: string | null;
instance_role: InstanceRole;
is_active: boolean;
role: AppRole;
created_at: string;
}
export interface GrantAppMemberInput {
user_id: string;
role: AppRole;
}
export interface ApiKeyDto {
id: string;
prefix: string;
@@ -472,6 +494,28 @@ export const api = {
)
},
appMembers: {
list: (idOrSlug: string) =>
adminRequest<AppMemberDto[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`
),
add: (idOrSlug: string, input: GrantAppMemberInput) =>
adminRequest<AppMemberDto>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`,
{ method: 'POST', body: JSON.stringify(input) }
),
setRole: (idOrSlug: string, userId: string, role: AppRole) =>
adminRequest<AppMemberDto>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'PATCH', body: JSON.stringify({ role }) }
),
remove: (idOrSlug: string, userId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'DELETE' }
)
},
execute: async (
id: string,
body: unknown,

View File

@@ -5,26 +5,38 @@
import {
api,
ApiError,
type AdminDto,
type App,
type AppDomain,
type AppMemberDto,
type AppRole,
type Script
} from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth';
const me = $derived($currentUser);
const SAMPLE_SOURCE =
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
type Tab = 'scripts' | 'domains' | 'settings';
type Tab = 'scripts' | 'domains' | 'members' | 'settings';
let slug = $derived(page.params.slug ?? '');
let app = $state<App | null>(null);
let myRole = $state<AppRole | null>(null);
let loadError = $state<string | null>(null);
let loading = $state(true);
let activeTab = $state<Tab>('scripts');
let scripts = $state<Script[]>([]);
let domains = $state<AppDomain[]>([]);
let members = $state<AppMemberDto[]>([]);
const canAdminMembers = $derived(myRole === 'app_admin');
// Script create
let showCreateScript = $state(false);
@@ -55,6 +67,19 @@
let removingDomain = $state(false);
let removeDomainError = $state<string | null>(null);
// Members tab
let eligibleUsers = $state<AdminDto[]>([]);
let eligibleLoadError = $state<string | null>(null);
let addMemberUserId = $state('');
let addMemberRole = $state<AppRole>('viewer');
let addingMember = $state(false);
let addMemberError = $state<string | null>(null);
let memberToRemove = $state<AppMemberDto | null>(null);
let removingMember = $state(false);
let removeMemberError = $state<string | null>(null);
let roleChangeBusy = $state<string | null>(null);
let memberActionError = $state<string | null>(null);
async function loadApp() {
loading = true;
loadError = null;
@@ -72,10 +97,15 @@
created_at: fetched.created_at,
updated_at: fetched.updated_at
};
myRole = fetched.my_role;
editName = app.name;
editDescription = app.description ?? '';
editSlug = app.slug;
await Promise.all([loadScripts(app.id), loadDomains(app.id)]);
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
if (canAdminMembers) {
loaders.push(loadMembers(app.id), loadEligibleUsers());
}
await Promise.all(loaders);
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
} finally {
@@ -101,6 +131,42 @@
}
}
async function loadMembers(appId: string) {
try {
members = await api.appMembers.list(appId);
} catch (e) {
members = [];
memberActionError = e instanceof Error ? e.message : String(e);
}
}
async function loadEligibleUsers() {
eligibleLoadError = null;
try {
const all = await api.admins.list();
// Only inactive=false members are valid invite targets — the
// API rejects everyone else anyway, so filter upfront.
eligibleUsers = all.filter(
(u) => u.is_active && u.instance_role === 'member'
);
} catch (e) {
eligibleUsers = [];
// member-with-app_admin can hit /apps/.../members but cannot
// browse /admins (gated on InstanceManageUsers). The add form
// will render disabled with the explanatory message below.
eligibleLoadError =
e instanceof ApiError && e.status === 403
? 'Only instance owners/admins can browse the user directory to invite new members.'
: e instanceof Error
? e.message
: String(e);
}
}
const eligibleAfterFilter = $derived(
eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id))
);
async function submitCreateScript(event: Event) {
event.preventDefault();
if (!app) return;
@@ -201,6 +267,76 @@
}
}
async function submitAddMember(event: Event) {
event.preventDefault();
if (!app || !addMemberUserId) return;
addingMember = true;
addMemberError = null;
try {
await api.appMembers.add(app.id, {
user_id: addMemberUserId,
role: addMemberRole
});
addMemberUserId = '';
addMemberRole = 'viewer';
await loadMembers(app.id);
} catch (e) {
addMemberError = e instanceof Error ? e.message : String(e);
} finally {
addingMember = false;
}
}
async function changeMemberRole(member: AppMemberDto, role: AppRole) {
if (!app || member.role === role) return;
roleChangeBusy = member.user_id;
memberActionError = null;
try {
await api.appMembers.setRole(app.id, member.user_id, role);
await loadMembers(app.id);
} catch (e) {
memberActionError = e instanceof Error ? e.message : String(e);
} finally {
roleChangeBusy = null;
}
}
function askRemoveMember(member: AppMemberDto) {
removeMemberError = null;
memberToRemove = member;
}
async function confirmRemoveMember() {
if (!app || !memberToRemove) return;
removingMember = true;
removeMemberError = null;
try {
const removedSelf = !!me && memberToRemove.user_id === me.id;
await api.appMembers.remove(app.id, memberToRemove.user_id);
memberToRemove = null;
if (removedSelf) {
// We just revoked our own access to this app; the next
// fetch of /apps/{slug} would 403. Bounce back to the
// apps list rather than render a broken tab.
await goto(`${base}/apps`);
return;
}
await loadMembers(app.id);
} catch (e) {
removeMemberError = e instanceof Error ? e.message : String(e);
} finally {
removingMember = false;
}
}
function shortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString();
} catch {
return iso;
}
}
function askDeleteApp() {
deleteAppError = null;
confirmingDeleteApp = true;
@@ -258,6 +394,13 @@
class:active={activeTab === 'domains'}
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
>
{#if canAdminMembers}
<button
type="button"
class:active={activeTab === 'members'}
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
>
{/if}
<button
type="button"
class:active={activeTab === 'settings'}
@@ -365,6 +508,121 @@
</ul>
{/if}
</section>
{:else if activeTab === 'members' && canAdminMembers}
<section>
<h2>Members</h2>
<p class="muted">
Users with explicit access to this app. Instance owners and admins
already have implicit access — they are not listed here. Use the Users
page to invite a <code>member</code> first, then grant them app access
below.
</p>
<form class="create-form" onsubmit={submitAddMember}>
<div class="row">
<label class="grow">
<span>User</span>
<select
bind:value={addMemberUserId}
disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
required
>
<option value="" disabled>Pick a member to invite…</option>
{#each eligibleAfterFilter as u (u.id)}
<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
{/each}
</select>
</label>
<label>
<span>Role</span>
<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
<option value="viewer">viewer</option>
<option value="editor">editor</option>
<option value="app_admin">app admin</option>
</select>
</label>
</div>
{#if eligibleLoadError}
<p class="muted">{eligibleLoadError}</p>
{:else if eligibleAfterFilter.length === 0}
<p class="muted">
No eligible users to invite. Create a <code>member</code> on the Users
page first.
</p>
{/if}
{#if addMemberError}
<div class="error">{addMemberError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
>
{addingMember ? 'Adding…' : 'Add member'}
</button>
</div>
</form>
{#if memberActionError}
<div class="error">{memberActionError}</div>
{/if}
{#if members.length === 0}
<p class="muted">No explicit members yet.</p>
{:else}
<div class="table">
<div class="row head-row">
<div>User</div>
<div>Instance</div>
<div>App role</div>
<div>Joined</div>
<div class="actions-col"></div>
</div>
{#each members as m (m.user_id)}
<div class="row member-row" class:inactive={!m.is_active}>
<div>
<strong>{m.username}</strong>
{#if m.email}<span class="muted">{m.email}</span>{/if}
{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
</div>
<div><RoleChip role={m.instance_role} size="sm" /></div>
<div><RoleChip appRole={m.role} size="sm" /></div>
<div>{shortDate(m.created_at)}</div>
<div class="actions-col">
<ActionMenu
label="Member actions for {m.username}"
items={[
{
label: 'Make app admin',
disabled:
m.role === 'app_admin' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'app_admin')
},
{
label: 'Make editor',
disabled:
m.role === 'editor' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'editor')
},
{
label: 'Make viewer',
disabled:
m.role === 'viewer' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'viewer')
},
{
label: 'Remove from app',
danger: true,
onClick: () => askRemoveMember(m)
}
]}
/>
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else if activeTab === 'settings'}
<section>
<h2>Settings</h2>
@@ -502,6 +760,26 @@
{/if}
</ConfirmModal>
{/if}
{#if memberToRemove}
<ConfirmModal
title="Remove {memberToRemove.username} from {app.name}"
variant="danger"
confirmLabel="Remove member"
busyLabel="Removing…"
busy={removingMember}
onConfirm={confirmRemoveMember}
onCancel={() => (memberToRemove = null)}
>
<p>
<strong>{memberToRemove.username}</strong> will lose access to this
app. Their other app memberships and account are untouched.
</p>
{#if removeMemberError}
<p class="modal-error">{removeMemberError}</p>
{/if}
</ConfirmModal>
{/if}
{/if}
<style>
@@ -744,4 +1022,60 @@
border-radius: 0.5rem;
background: #1e0a0a;
}
.create-form select {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.create-form .row > label.grow {
grid-column: span 2;
}
.table {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.table .row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
gap: 0.75rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
align-items: center;
margin: 0;
}
.table .head-row {
background: transparent;
padding: 0.25rem 1rem;
color: #64748b;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table .member-row.inactive {
opacity: 0.55;
}
.table .member-row strong {
margin-right: 0.4rem;
}
.table .member-row .muted {
font-size: 0.8rem;
}
.table .actions-col {
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,75 @@
# Dashboard E2E tests
Browser-driven tests for the PiCloud dashboard, powered by [Playwright].
## Prerequisites
The tests drive a real dashboard against a real backend. Bring up both
before running:
```sh
# 1. Postgres
docker compose up -d postgres
# 2. Backend (port 18080 matches dashboard/vite.config.ts dev proxy)
PICLOUD_BIND=127.0.0.1:18080 \
PICLOUD_ADMIN_USERNAME=admin \
PICLOUD_ADMIN_PASSWORD=admin \
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
cargo run -p picloud
# 3. Browser binaries (one-time, ~200 MB)
cd dashboard && npm run test:e2e:install
```
The Vite dev server is started automatically by Playwright's `webServer`
config — you do not need to run `npm run dev` yourself.
## Running
```sh
cd dashboard
npm run test:e2e # headless, full suite
npm run test:e2e:ui # interactive UI runner
npx playwright test smoke # run a single spec
npx playwright show-report
```
## Env vars
| Var | Default | Notes |
| ------------------------ | ------------------------ | ----------------------------------------------------------------- |
| `E2E_BASE_URL` | `http://localhost:5173` | Origin tests navigate against (dashboard is mounted at `/admin`). |
| `E2E_API_BASE` | `http://127.0.0.1:18080` | Backend used by globalSetup health probe + admin login. |
| `E2E_DASHBOARD_ORIGIN` | `http://localhost:5173` | Used to seed `localStorage` during globalSetup. |
| `E2E_ADMIN_USERNAME` | `admin` | Bootstrap admin to log in as. |
| `E2E_ADMIN_PASSWORD` | `admin` | Match `PICLOUD_ADMIN_PASSWORD` above. |
| `PICLOUD_DASHBOARD_PORT` | `5173` | Dev server port — picked up by both Vite and Playwright. |
## How isolation works
Tests share one backend + one Postgres. To avoid cross-test interference:
- A shared bootstrap admin session is captured once in
`tests/e2e/.auth/admin.json` (gitignored) and reused by every test via
`storageState`.
- Each test creates resources with a unique slug / username produced by
`fixtures/ids.ts` (`e2e-<prefix>-w<worker>-<random>`).
- Each test registers cleanup via `fixtures/cleanup.ts` and tears down
in `afterEach`. Cleanup is best-effort: a missing resource doesn't
fail the suite, so a test can pre-delete and still register the entry.
## Layout
```
tests/e2e/
global-setup.ts # health probe + admin login + storageState seed
smoke.spec.ts # A.5 smoke
fixtures/
auth.ts # UI login/logout helpers (for login-flow specs)
api.ts # bearer-token-backed APIRequestContext
ids.ts # unique slug/username generators (test-fixture)
cleanup.ts # afterEach resource teardown
```
[Playwright]: https://playwright.dev

View File

@@ -0,0 +1,226 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
// historical-slug takeover flow and adversarial inputs.
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
function failOnDialog(page: Page): void {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
});
}
async function openCreateForm(page: Page): Promise<void> {
await page.goto('/admin/apps');
await page.getByRole('button', { name: 'New app' }).click();
}
async function createApp(
page: Page,
opts: { name: string; slug: string; description?: string }
): Promise<void> {
await openCreateForm(page);
await page.getByLabel('Name').fill(opts.name);
// Clear the auto-derived slug and type the test-controlled one so
// we know exactly which slug we'll register for cleanup.
const slugInput = page.getByLabel('Slug');
await slugInput.fill('');
await slugInput.fill(opts.slug);
if (opts.description !== undefined) {
await page.getByLabel('Description').fill(opts.description);
}
await page.getByRole('button', { name: 'Create app' }).click();
}
test.describe('B2 apps lifecycle', () => {
test('create app: slug auto-derives from name, app appears in list', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('lifecycle');
const displayName = slug.replace(/-/g, ' ');
await openCreateForm(page);
await page.getByLabel('Name').fill(displayName);
// Slug auto-derives — the input value is set, no extra typing.
const slugInput = page.getByLabel('Slug');
await expect(slugInput).toHaveValue(slug);
await page.getByRole('button', { name: 'Create app' }).click();
cleanup.app(slug);
await expect(page.getByRole('link', { name: new RegExp(displayName) })).toBeVisible();
});
test('edit name + description in settings persists across reload', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('edit');
await createApp(page, { name: slug, slug });
cleanup.app(slug);
await page.getByRole('link', { name: new RegExp(slug) }).click();
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
await page.getByRole('button', { name: 'Settings' }).click();
const newName = `${slug} renamed`;
const newDesc = 'updated description';
await page.getByLabel('Name').fill(newName);
await page.getByLabel('Description').fill(newDesc);
await page.getByRole('button', { name: 'Save changes' }).click();
// Wait for the network round-trip to settle — the busy label
// flips back to "Save changes" when done.
await expect(page.getByRole('button', { name: 'Save changes' })).toBeEnabled();
await page.reload();
await page.getByRole('button', { name: 'Settings' }).click();
await expect(page.getByLabel('Name')).toHaveValue(newName);
await expect(page.getByLabel('Description')).toHaveValue(newDesc);
});
test('delete: wrong phrase keeps button disabled, right phrase removes app', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('delete');
await createApp(page, { name: slug, slug });
cleanup.app(slug); // belt-and-braces; cleanup is best-effort
await page.getByRole('link', { name: new RegExp(slug) }).click();
await page.getByRole('button', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Delete app' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const phraseInput = dialog.getByRole('textbox');
const confirmBtn = dialog.getByRole('button', { name: 'Delete app' });
await expect(confirmBtn).toBeDisabled();
await phraseInput.fill('wrong-phrase');
await expect(confirmBtn).toBeDisabled();
await phraseInput.fill(slug);
await expect(confirmBtn).toBeEnabled();
await confirmBtn.click();
await expect(page).toHaveURL(/\/admin\/apps$/);
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
});
test('historical slug warning surfaces; force-takeover succeeds', async ({
page,
uniqueSlug
}) => {
const origSlug = uniqueSlug('hist');
const renamedSlug = `${origSlug}-r`;
// Historical-redirect rows are created on RENAME, not on
// delete. So: create app, rename it, original slug now lives
// in app_slug_history.
const api = await adminApi();
try {
const created = await api.post('/api/v1/admin/apps', {
data: { slug: origSlug, name: origSlug }
});
expect(created.ok()).toBe(true);
const renamed = await api.patch(
`/api/v1/admin/apps/${encodeURIComponent(origSlug)}`,
{ data: { slug: renamedSlug } }
);
expect(renamed.ok()).toBe(true);
} finally {
await api.dispose();
}
cleanup.app(renamedSlug); // the renamed app still exists
await openCreateForm(page);
await page.getByLabel('Name').fill(origSlug);
await page.getByLabel('Slug').fill('');
await page.getByLabel('Slug').fill(origSlug);
await page.getByRole('button', { name: 'Create app' }).click();
await expect(page.locator('.warning')).toBeVisible();
await expect(page.locator('.warning')).toContainText(/previously redirected/i);
await page.getByRole('button', { name: /claim slug anyway/i }).click();
cleanup.app(origSlug); // the takeover created a new app
await expect(page.getByRole('link', { name: new RegExp(origSlug) })).toBeVisible();
});
});
test.describe('B2 apps adversarial', () => {
test('slug with uppercase + spaces is normalized in-place', async ({ page, uniqueSlug }) => {
const base = uniqueSlug('norm');
await openCreateForm(page);
await page.getByLabel('Name').fill(base);
const slugInput = page.getByLabel('Slug');
await slugInput.fill('');
// Simulate the user typing/pasting an invalid slug. The
// oninput handler runs slugify() and rewrites the input value.
await slugInput.fill(` Hello WORLD ${base}!`);
await expect(slugInput).toHaveValue(`hello-world-${base}`);
});
test('xss in name and description renders as text everywhere', async ({ page, uniqueSlug }) => {
failOnDialog(page);
const slug = uniqueSlug('xss');
const payload = '<img src=x onerror=alert(1)><script>window.__xss=true;</script>';
await createApp(page, { name: payload, slug, description: payload });
cleanup.app(slug);
// List page — the link's accessible name contains the literal
// payload text, not the parsed HTML.
await expect(page.getByRole('link', { name: new RegExp('img src=x') })).toBeVisible();
// Detail page — open it; payload renders in the breadcrumb /
// header as text only.
await page.goto(`/admin/apps/${slug}`);
const xssRan = await page.evaluate(
() => (window as unknown as { __xss?: boolean }).__xss === true
);
expect(xssRan).toBe(false);
expect(await page.locator('script:has-text("__xss")').count()).toBe(0);
});
test('very long name does not crash the dashboard', async ({ page, uniqueSlug }) => {
// The backend currently has no name length cap; the dashboard
// just needs to keep rendering when handed an unusually long
// value. Guards against layout / locator regressions when a
// future test or user creates an oversized app.
const slug = uniqueSlug('long');
const longName = 'A'.repeat(10_000);
await openCreateForm(page);
await page.getByLabel('Name').fill(longName);
await page.getByLabel('Slug').fill('');
await page.getByLabel('Slug').fill(slug);
await page.getByRole('button', { name: 'Create app' }).click();
const errorVisible = await page
.locator('.create-form .error')
.isVisible()
.catch(() => false);
if (errorVisible) {
// Server rejected — fine, no cleanup needed.
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
return;
}
// Server accepted — confirm the dashboard still renders and is
// navigable. Detail page must load too.
cleanup.app(slug);
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
await page.goto(`/admin/apps/${slug}`);
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
});
});

View File

@@ -0,0 +1,118 @@
import { expect, test, type Page } from '@playwright/test';
import { loginAsAdmin, logout } from '../fixtures/auth';
// Phase B1 — Auth & Navigation. Every interaction with the login form
// and the layout-level redirects, plus the obvious adversarial inputs.
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
function failOnDialog(page: Page): void {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
});
}
test.describe('B1 auth — unauthenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('valid credentials land on the apps list', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
});
test('wrong password shows an inline error and stays on /login', async ({ page }) => {
await page.goto('/admin/login');
await page.getByLabel('Username').fill(VALID_USERNAME);
await page.getByLabel('Password').fill('definitely-not-the-password');
await page.getByRole('button', { name: /sign in/i }).click();
const error = page.locator('.error');
await expect(error).toBeVisible();
await expect(error).not.toHaveText('');
await expect(page).toHaveURL(/\/admin\/login$/);
// localStorage must remain empty — a failed login should not
// leak a session token.
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
expect(token).toBeNull();
});
test('empty submit is blocked by the browser and does not navigate', async ({ page }) => {
await page.goto('/admin/login');
await page.getByRole('button', { name: /sign in/i }).click();
// HTML5 validation prevents submission; URL is unchanged and the
// username input is reported invalid.
await expect(page).toHaveURL(/\/admin\/login$/);
const usernameInvalid = await page
.getByLabel('Username')
.evaluate((el: HTMLInputElement) => !el.validity.valid);
expect(usernameInvalid).toBe(true);
await expect(page.locator('.error')).toBeHidden();
});
test('visiting an authed route redirects to /login', async ({ page }) => {
await page.goto('/admin/apps');
await expect(page).toHaveURL(/\/admin\/login$/);
await expect(page.getByLabel('Username')).toBeVisible();
});
test('password field is type=password (no plaintext echo)', async ({ page }) => {
await page.goto('/admin/login');
await expect(page.getByLabel('Password')).toHaveAttribute('type', 'password');
});
test('xss payload in username is escaped and does not execute', async ({ page }) => {
failOnDialog(page);
const payload = '<script>window.__xss = true;</script><img src=x onerror=alert(1)>';
await page.goto('/admin/login');
await page.getByLabel('Username').fill(payload);
await page.getByLabel('Password').fill('whatever');
await page.getByRole('button', { name: /sign in/i }).click();
// Whatever the API does with that input, the page must remain
// safe: no script tag injected into the DOM, no global side
// effect, and a visible error (since the credentials don't
// match any user).
await expect(page.locator('.error')).toBeVisible();
const xssRan = await page.evaluate(
() => (window as unknown as { __xss?: boolean }).__xss === true
);
expect(xssRan).toBe(false);
const injectedScript = await page.locator('script:has-text("__xss")').count();
expect(injectedScript).toBe(0);
// The form must still be functional after the rejected attempt.
await page.getByLabel('Username').fill('');
await page.getByLabel('Username').fill(VALID_USERNAME);
await page.getByLabel('Password').fill('');
await page.getByLabel('Password').fill(VALID_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/admin\/apps$/);
});
});
test.describe('B1 auth — authenticated', () => {
test('visiting /login while signed in bounces to /apps', async ({ page }) => {
await page.goto('/admin/login');
await expect(page).toHaveURL(/\/admin\/apps$/);
});
});
test.describe('B1 auth — logout', () => {
// Logout must NOT use the shared storageState token, or it would
// invalidate the session every other test relies on. Each run
// here logs in fresh so its session is disposable.
test.use({ storageState: { cookies: [], origins: [] } });
test('logout clears the session and lands on /login', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
await logout(page);
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
expect(token).toBeNull();
// And the authed area is now gated again.
await page.goto('/admin/apps');
await expect(page).toHaveURL(/\/admin\/login$/);
});
});

View File

@@ -0,0 +1,47 @@
import { request, type APIRequestContext } from '@playwright/test';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const STATE_PATH = path.join(__dirname, '..', '.auth', 'admin.json');
interface StoredState {
origins: Array<{
origin: string;
localStorage: Array<{ name: string; value: string }>;
}>;
}
let cachedToken: string | null = null;
async function readAdminToken(): Promise<string> {
if (cachedToken) return cachedToken;
const raw = await fs.readFile(STATE_PATH, 'utf8');
const state = JSON.parse(raw) as StoredState;
for (const origin of state.origins) {
const entry = origin.localStorage.find((e) => e.name === 'picloud.admin.token');
if (entry) {
cachedToken = entry.value;
return entry.value;
}
}
throw new Error(`No picloud.admin.token in ${STATE_PATH} — did globalSetup run?`);
}
// Thin wrapper around Playwright's request context that injects the
// admin bearer token from the shared storageState. Use this for
// setup/teardown shortcuts when the *test itself* is about something
// else (e.g., a script-editor test that just needs an app to exist).
export async function adminApi(): Promise<APIRequestContext> {
const token = await readAdminToken();
return request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: {
authorization: `Bearer ${token}`,
'content-type': 'application/json'
}
});
}

View File

@@ -0,0 +1,21 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
// Drive the login form like a real user. globalSetup already saves a
// storageState for the shared admin, so most tests don't need this —
// it's reserved for specs that explicitly cover the login UI.
export async function loginAsAdmin(page: Page): Promise<void> {
await page.goto('/admin/login');
await page.getByLabel('Username').fill(ADMIN_USERNAME);
await page.getByLabel('Password').fill(ADMIN_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/admin\/apps$/);
}
export async function logout(page: Page): Promise<void> {
await page.getByRole('button', { name: /logout/i }).click();
await expect(page).toHaveURL(/\/admin\/login$/);
}

View File

@@ -0,0 +1,48 @@
import type { APIRequestContext } from '@playwright/test';
import { adminApi } from './api';
// Resources to delete after a test, in LIFO order. Tests register
// their creations and the registry tears everything down in
// `cleanupRegistered` — typically called from `test.afterEach`.
type Cleanup = (api: APIRequestContext) => Promise<void>;
export class CleanupRegistry {
private items: Cleanup[] = [];
app(slugOrId: string): void {
this.items.push(async (api) => {
await api.delete(`/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`);
});
}
adminUser(userId: string): void {
this.items.push(async (api) => {
await api.delete(`/api/v1/admin/admins/${userId}`);
});
}
apiKey(keyId: string): void {
this.items.push(async (api) => {
await api.delete(`/api/v1/admin/api-keys/${keyId}`);
});
}
async run(): Promise<void> {
if (this.items.length === 0) return;
const api = await adminApi();
try {
for (const item of this.items.reverse()) {
try {
await item(api);
} catch {
// Best-effort cleanup — a missing resource (already
// deleted by the test) shouldn't fail the suite.
}
}
} finally {
await api.dispose();
this.items = [];
}
}
}

View File

@@ -0,0 +1,42 @@
/* eslint-disable no-empty-pattern -- Playwright fixtures require an
object-pattern first arg; these fixtures don't depend on any other
fixture so the pattern is intentionally empty. */
import { test as base } from '@playwright/test';
import { randomBytes } from 'node:crypto';
// Tests share a single backend/Postgres. To avoid collisions we tag
// every resource the test creates with a short random suffix plus the
// Playwright worker index. This way two workers running the same spec
// in parallel never fight over the same slug or username.
export function shortId(): string {
return randomBytes(3).toString('hex');
}
export function uniqueSlug(prefix: string, workerIndex: number): string {
const cleaned = prefix
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return `e2e-${cleaned}-w${workerIndex}-${shortId()}`;
}
export function uniqueUsername(prefix: string, workerIndex: number): string {
// Username regex is [a-z0-9._-]{2,32}. Mirror the slug format.
const cleaned = prefix.toLowerCase().replace(/[^a-z0-9]+/g, '');
return `e2e${cleaned}w${workerIndex}${shortId()}`.slice(0, 32);
}
export const test = base.extend<{
uniqueSlug: (prefix: string) => string;
uniqueUsername: (prefix: string) => string;
}>({
uniqueSlug: async ({}, use, testInfo) => {
await use((prefix) => uniqueSlug(prefix, testInfo.workerIndex));
},
uniqueUsername: async ({}, use, testInfo) => {
await use((prefix) => uniqueUsername(prefix, testInfo.workerIndex));
}
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,94 @@
import { chromium, request } from '@playwright/test';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
const DASHBOARD_ORIGIN = process.env.E2E_DASHBOARD_ORIGIN ?? `http://localhost:${DASHBOARD_PORT}`;
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
const AUTH_DIR = path.join(__dirname, '.auth');
const ADMIN_STATE_PATH = path.join(AUTH_DIR, 'admin.json');
export default async function globalSetup(): Promise<void> {
await assertBackendUp();
await fs.mkdir(AUTH_DIR, { recursive: true });
const token = await loginAsAdmin();
await persistAdminStorageState(token);
}
async function assertBackendUp(): Promise<void> {
const probe = await request.newContext();
try {
const res = await probe.get(`${API_BASE}/healthz`, { timeout: 5_000 });
if (!res.ok()) {
throw new Error(
`backend /healthz returned ${res.status()} — is \`cargo run -p picloud\` listening on ${API_BASE}?`
);
}
} catch (err) {
throw new Error(
`Could not reach backend at ${API_BASE}/healthz. ` +
`Bring it up before running E2E tests:\n\n` +
` docker compose up -d postgres\n` +
` PICLOUD_BIND=127.0.0.1:18080 \\\n` +
` PICLOUD_ADMIN_USERNAME=${ADMIN_USERNAME} \\\n` +
` PICLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD} \\\n` +
` DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \\\n` +
` cargo run -p picloud\n\n` +
`Underlying error: ${(err as Error).message}`
);
} finally {
await probe.dispose();
}
}
async function loginAsAdmin(): Promise<string> {
const ctx = await request.newContext();
try {
const res = await ctx.post(`${API_BASE}/api/v1/admin/auth/login`, {
data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD },
headers: { 'content-type': 'application/json' }
});
if (!res.ok()) {
const body = await res.text();
throw new Error(
`Admin login failed (${res.status()}): ${body}. ` +
`Verify PICLOUD_ADMIN_USERNAME / PICLOUD_ADMIN_PASSWORD match the seeded bootstrap admin.`
);
}
const payload = (await res.json()) as { token?: string };
if (!payload.token) {
throw new Error('Admin login response missing token field');
}
return payload.token;
} finally {
await ctx.dispose();
}
}
// The dashboard reads its session from localStorage under the key
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to
// localStorage without a browser context, so launch a throwaway one,
// seed the value, then save storageState for every test to reuse.
async function persistAdminStorageState(token: string): Promise<void> {
const browser = await chromium.launch();
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${DASHBOARD_ORIGIN}/admin/login`);
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
await context.storageState({ path: ADMIN_STATE_PATH });
} finally {
await browser.close();
}
}

View File

@@ -0,0 +1,155 @@
import { expect, request, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Full-stack integration scenarios. Unlike the per-page B1B8 specs,
// these drive a complete user journey across multiple pages and then
// verify the data plane / API surface behaves the way the dashboard
// promised it would.
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
const cm = page.locator(locator).first();
await cm.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type(text);
}
test('end-to-end: app + domain + script + route via dashboard → invoke via public URL', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('public');
const domain = `${slug}.local`;
const routePath = `/${slug}/hello`;
const scriptName = `${slug}-hello`;
const scriptSource = `return #{ statusCode: 200, body: #{ source: "public", slug: "${slug}" } };`;
// 1. Create the app from the apps list.
await page.goto('/admin/apps');
await page.getByRole('button', { name: 'New app' }).click();
await page.getByLabel('Name').fill(slug);
const slugInput = page.getByLabel('Slug');
await slugInput.fill('');
await slugInput.fill(slug);
await page.getByRole('button', { name: 'Create app' }).click();
cleanup.app(slug);
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
// 2. Open the app and claim the domain on the Domains tab.
await page.getByRole('link', { name: new RegExp(slug) }).click();
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
const domainForm = page.locator('form.create-form.inline');
await domainForm.getByPlaceholder(/app\.example\.com/).fill(domain);
await domainForm.getByRole('button', { name: /^Add domain$/ }).click();
await expect(page.locator('.domain-row')).toContainText(domain);
// 3. Create the script on the Scripts tab.
await page.getByRole('button', { name: /^Scripts \(\d+\)$/ }).click();
await page.getByRole('button', { name: /^New script$/ }).click();
await page.getByLabel('Name').fill(scriptName);
await fillCodeMirror(page, '.cm-content', scriptSource);
await page.getByRole('button', { name: /^Create script$/ }).click();
// 4. Open the script and bind a route on the Routing tab.
await page.getByRole('link', { name: new RegExp(scriptName) }).click();
await page.getByRole('button', { name: 'Routing' }).click();
await page.getByRole('button', { name: '+ Add route' }).click();
const routeForm = page.locator('form.route-form');
await routeForm.getByLabel('Path', { exact: true }).fill(routePath);
await routeForm.getByLabel('Method').selectOption('GET');
await routeForm.getByLabel(/^Host/).fill(domain);
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText(routePath);
// 5. Invoke via the public URL, with the Host header pointing at
// the claimed domain. The dev backend listens on 127.0.0.1; the
// orchestrator resolves the app from Host, then the route.
const publicCtx = await request.newContext({ baseURL: API_BASE });
try {
const res = await publicCtx.get(routePath, { headers: { host: domain } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { source: string; slug: string };
expect(body.source).toBe('public');
expect(body.slug).toBe(slug);
} finally {
await publicCtx.dispose();
}
});
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
page
}) => {
const name = `e2e-cli-${Date.now()}`;
// 1. Mint the key from /profile and capture the revealed token.
await page.goto('/admin/profile');
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
const mintForm = page.locator('form.mint');
await mintForm.getByPlaceholder('e.g. ci-deploy').fill(name);
// script:read is enough to read the scripts list — that's our
// "CLI verb" below.
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await page.getByRole('button', { name: /^Mint key$/ }).click();
const reveal = page.locator('.reveal');
await expect(reveal).toBeVisible();
const rawToken = (await reveal.locator('code.token').textContent())?.trim();
expect(rawToken).toBeTruthy();
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
// 2. Act like a CLI: call the API directly with Bearer <token>.
const cli = await request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: { authorization: `Bearer ${rawToken}` }
});
try {
const ok = await cli.get('/api/v1/admin/scripts');
expect(ok.status()).toBe(200);
const body = (await ok.json()) as unknown;
expect(Array.isArray(body)).toBe(true);
// Sanity: a route the scope doesn't cover must reject.
// `script:read` cannot list instance admins (that's
// instance:admin territory).
const denied = await cli.get('/api/v1/admin/admins');
expect(denied.status()).toBe(403);
// 3. Revoke via the dashboard.
await page.reload();
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
await expect(revokeBtn).toBeVisible();
await revokeBtn.click();
await page.getByRole('dialog').getByRole('button', { name: /^Revoke$/ }).click();
await expect(revokeBtn).toHaveCount(0);
// 4. Same CLI call must now fail auth.
const afterRevoke = await cli.get('/api/v1/admin/scripts');
expect(afterRevoke.status()).toBe(401);
} finally {
await cli.dispose();
}
// Belt-and-braces cleanup: if the UI revoke missed, drop via API.
const api = await adminApi();
try {
const list = await api.get('/api/v1/admin/api-keys');
if (list.ok()) {
const all = (await list.json()) as Array<{ id: string; name: string }>;
const k = all.find((x) => x.name === name);
if (k) cleanup.apiKey(k.id);
}
} finally {
await api.dispose();
}
});

View File

@@ -0,0 +1,199 @@
import { expect, type Browser, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B5 — App Members. Setup creates one or two extra admin
// users via the API; tests drive the Members tab through the
// dashboard like a real app admin would.
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createApp(slug: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
async function createMemberUser(username: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/admins', {
data: { username, password: 'e2e-member-pw', instance_role: 'member' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
async function loginAsUserToken(username: string, password: string): Promise<string> {
const probe = await (await import('@playwright/test')).request.newContext({
baseURL: API_BASE
});
try {
const res = await probe.post('/api/v1/admin/auth/login', {
data: { username, password },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { token: string }).token;
} finally {
await probe.dispose();
}
}
async function pageWithUserToken(browser: Browser, token: string): Promise<Page> {
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
// Seed localStorage on the right origin, then navigate normally.
await page.goto('/admin/login');
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
return page;
}
test.describe('B5 app members', () => {
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('inv');
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
// Invite. Both selects sit in `form.create-form`; locate them
// by position to avoid getByLabel ambiguity (the Svelte
// markup nests both labels in a flex row, which makes their
// accessible names overlap).
const form = page.locator('form.create-form');
await form.locator('select').nth(0).selectOption({ label: username });
await form.locator('select').nth(1).selectOption('editor');
await page.getByRole('button', { name: /^Add member$/ }).click();
await expect(page.locator('.member-row')).toContainText(username);
// Remove via action menu + confirm modal.
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Remove from app$/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Remove member$/ }).click();
await expect(page.locator('.member-row')).toHaveCount(0);
});
test('role change via action menu updates the role chip', async ({
page,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('role');
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
// Seed the membership via API to skip the invite UI.
const api = await adminApi();
try {
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
data: { user_id: userId, role: 'viewer' }
});
expect(res.ok()).toBe(true);
} finally {
await api.dispose();
}
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Make editor$/ }).click();
const row = page.locator('.member-row', { hasText: username });
await expect(row).toContainText(/editor/i);
});
test('non-app-admin viewers do not see the Members tab', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('viewer');
const password = 'e2e-member-pw';
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
// Grant viewer membership (not app_admin) so the user can see
// the app at all.
const api = await adminApi();
try {
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
data: { user_id: userId, role: 'viewer' }
});
expect(res.ok()).toBe(true);
} finally {
await api.dispose();
}
const token = await loginAsUserToken(username, password);
const viewerPage = await pageWithUserToken(browser, token);
try {
await viewerPage.goto(`/admin/apps/${slug}`);
// Scripts tab loads — that's what a viewer sees.
await expect(
viewerPage.getByRole('button', { name: /^Scripts \(\d+\)$/ })
).toBeVisible();
// Members tab button is absent for non-app-admins.
await expect(
viewerPage.getByRole('button', { name: /^Members \(\d+\)$/ })
).toHaveCount(0);
} finally {
await viewerPage.context().close();
}
});
});
test.describe('B5 app members adversarial', () => {
test('role dropdown exposes only the documented values', async ({
page,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('mem');
const username = uniqueUsername('rolelist');
await createApp(slug);
const userId = await createMemberUser(username);
cleanup.app(slug);
cleanup.adminUser(userId);
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
const form = page.locator('form.create-form');
const roleSelect = form.locator('select').nth(1);
const optionValues = await roleSelect.evaluate((el: HTMLSelectElement) =>
Array.from(el.options).map((o) => o.value)
);
expect(optionValues.sort()).toEqual(['app_admin', 'editor', 'viewer']);
});
});

View File

@@ -0,0 +1,150 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B7 — Profile + API Keys (/admin/profile). Covers the
// mint/reveal/revoke flow, the app-binding mutual-exclusion guard,
// and adversarial inputs.
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createApp(slug: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
async function openMintForm(page: Page): Promise<void> {
await page.goto('/admin/profile');
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
}
async function registerKeyCleanupByName(name: string): Promise<void> {
const api = await adminApi();
try {
const res = await api.get('/api/v1/admin/api-keys');
const all = (await res.json()) as Array<{ id: string; name: string }>;
const k = all.find((x) => x.name === name);
if (k) cleanup.apiKey(k.id);
} finally {
await api.dispose();
}
}
test.describe('B7 profile + API keys', () => {
test('mint instance-wide key: reveal → ack → key appears in list', async ({ page }) => {
const name = `e2e-mint-${Date.now()}`;
await openMintForm(page);
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
// Pick a non-instance scope so we don't need to worry about
// mutual exclusion here. The scope-chip is a <label> wrapping
// the checkbox — clicking the label toggles it.
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await page.getByRole('button', { name: /^Mint key$/ }).click();
const reveal = page.locator('.reveal');
await expect(reveal).toBeVisible();
await expect(reveal.locator('code.token')).toContainText(/\S{16,}/);
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
await registerKeyCleanupByName(name);
await expect(page.getByText(name)).toBeVisible();
});
test('binding to an app disables instance scopes', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('keyapp');
const appId = await createApp(slug);
cleanup.app(slug);
await openMintForm(page);
// Default binding is Instance-wide — instance scopes are
// enabled.
const instChip = page.locator('label.scope-chip', { hasText: 'instance:admin' });
await expect(instChip).not.toHaveClass(/disabled/);
// Switch binding to the app. The chip becomes disabled.
await page.getByLabel(/Binding/i).selectOption(appId);
await expect(instChip).toHaveClass(/disabled/);
});
test('revoke key removes it from the list', async ({ page }) => {
const name = `e2e-revoke-${Date.now()}`;
// Seed a key via API so the test focuses on the revoke UI.
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/api-keys', {
data: { name, scopes: ['script:read'] }
});
expect(res.ok()).toBe(true);
const body = (await res.json()) as { id: string };
cleanup.apiKey(body.id);
} finally {
await api.dispose();
}
await page.goto('/admin/profile');
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
await expect(revokeBtn).toBeVisible();
await revokeBtn.click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: /^Revoke$/ }).click();
// Assert the row's revoke button is gone (the flash banner
// also mentions the name, so a plain getByText would still
// match — anchor on the row-scoped button instead).
await expect(revokeBtn).toHaveCount(0);
});
test('denied=users banner shows when arriving from the users redirect', async ({ page }) => {
await page.goto('/admin/profile?denied=users');
await expect(page.getByText(/don.?t have access to the Users page/i)).toBeVisible();
});
});
test.describe('B7 profile adversarial', () => {
test('empty name keeps the mint button disabled', async ({ page }) => {
await openMintForm(page);
// Trying to click would HTML5-validate; instead verify the
// button is disabled while name is empty.
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await expect(page.getByRole('button', { name: /^Mint key$/ })).toBeDisabled();
});
test('copy-token button copies the full token, not a truncated form', async ({
page,
context
}) => {
// Permission must be granted explicitly; chromium will throw
// otherwise when calling navigator.clipboard.readText().
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const name = `e2e-copy-${Date.now()}`;
await openMintForm(page);
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
await page.getByRole('button', { name: /^Mint key$/ }).click();
const reveal = page.locator('.reveal');
const tokenInDom = await reveal.locator('code.token').textContent();
expect(tokenInDom).toBeTruthy();
await reveal.getByRole('button', { name: /^Copy$/ }).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toBe(tokenInDom);
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
await registerKeyCleanupByName(name);
});
});

View File

@@ -0,0 +1,189 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B4 — Routing tab in the script editor. Add / remove / match
// preview + validation paths (host check, path-kind mismatch, reserved
// prefix, duplicate conflict, adversarial paths).
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function makeAppWithScript(slug: string): Promise<{ appId: string; scriptId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug, name: slug }
});
expect(appRes.ok()).toBe(true);
const appBody = (await appRes.json()) as { id: string };
const scriptRes = await api.post('/api/v1/admin/scripts', {
data: { app_id: appBody.id, name: 'route-target', source: HELLO_RHAI }
});
expect(scriptRes.ok()).toBe(true);
const scriptBody = (await scriptRes.json()) as { id: string };
return { appId: appBody.id, scriptId: scriptBody.id };
} finally {
await api.dispose();
}
}
async function gotoRoutingTab(page: Page, scriptId: string): Promise<void> {
await page.goto(`/admin/scripts/${scriptId}`);
await page.getByRole('button', { name: 'Routing' }).click();
}
async function addRoute(
page: Page,
opts: { path: string; pathKind?: 'exact' | 'param' | 'prefix'; method?: string; host?: string }
): Promise<void> {
await page.getByRole('button', { name: '+ Add route' }).click();
const form = page.locator('form.route-form');
await form.getByLabel('Path', { exact: true }).fill(opts.path);
if (opts.pathKind) {
await form.getByLabel('Path kind').selectOption(opts.pathKind);
}
if (opts.method !== undefined) {
await form.getByLabel('Method').selectOption(opts.method);
}
if (opts.host !== undefined) {
await form.getByLabel(/^Host/).fill(opts.host);
}
}
test.describe('B4 routing', () => {
test('add route appears in list and matches in the preview', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('addr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/greet', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/greet');
// Match preview confirms the route resolves.
await page.getByLabel('URL').fill('http://localhost/greet');
await page.locator('.actions').getByRole('button', { name: 'Match' }).click();
await expect(page.locator('pre.preview')).toContainText('script_id');
});
test('remove route updates the list', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('remr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/transient', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/transient');
// removeRoute() uses window.confirm — accept it.
page.once('dialog', (d) => void d.accept());
await page.locator('.route-list').getByRole('button', { name: 'remove' }).click();
await expect(page.locator('.route-list')).toHaveCount(0);
await expect(page.getByText(/no routes yet/i)).toBeVisible();
});
test('duplicate route surfaces a 409 conflict error inline', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('dupr');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/twice', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-list')).toContainText('/twice');
// Same path + method again — must conflict.
await addRoute(page, { path: '/twice', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-form .error.inline')).toBeVisible();
});
test('path-kind mismatch warns inline when /:name is set to exact', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('mism');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await page.getByRole('button', { name: '+ Add route' }).click();
await page.getByLabel('Path', { exact: true }).fill('/users/:id');
// Override to a wrong kind — auto-detect would have picked
// `param`; selecting `exact` should fire the warning.
await page.getByLabel('Path kind').selectOption('exact');
await expect(page.locator('.route-form .warning.inline')).toBeVisible();
});
test('host validation warns when the host is not a claimed domain', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('unclaim');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await page.getByRole('button', { name: '+ Add route' }).click();
await page.getByLabel('Path', { exact: true }).fill('/x');
await page.getByLabel(/^Host/).fill('example.test-not-claimed.local');
// One of the inline warnings is the unclaimed-host explainer.
await expect(page.locator('.route-form .warning.inline').first()).toBeVisible();
});
});
test.describe('B4 routing adversarial', () => {
test('reserved prefix /api/ is rejected with a visible error', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('reserv');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, { path: '/api/v9/oops', method: 'GET' });
await page.getByRole('button', { name: /^Create route$/ }).click();
await expect(page.locator('.route-form .error.inline')).toBeVisible();
await expect(page.locator('.route-form .error.inline')).toContainText(
/reserved|api|prefix/i
);
// Empty-state copy renders when no routes exist; the path
// itself must not appear anywhere on the routing tab.
await expect(page.getByText(/no routes yet/i)).toBeVisible();
});
test('xss payload in path stored or rejected — never executes on render', async ({
page,
uniqueSlug
}) => {
page.on('dialog', async (d) => {
await d.dismiss();
throw new Error(`Unexpected dialog: ${d.message()}`);
});
const slug = uniqueSlug('pxss');
const { scriptId } = await makeAppWithScript(slug);
cleanup.app(slug);
await gotoRoutingTab(page, scriptId);
await addRoute(page, {
path: '/<script>alert(1)</script>',
method: 'GET'
});
await page.getByRole('button', { name: /^Create route$/ }).click();
// Either accepted (rendered as text in the list) or rejected
// (error inline). Both fine — what's NOT fine is an alert
// dialog or an injected <script> tag in the list.
const xssScripts = await page.locator('.route-list script:has-text("alert")').count();
expect(xssScripts).toBe(0);
});
});

View File

@@ -0,0 +1,203 @@
import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
// /admin/scripts/{id}. Setup uses the API to create the app (and
// sometimes a baseline script) so each test can focus on the editor
// flow it actually covers.
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createAppViaApi(slug: string): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/apps', {
data: { slug, name: slug }
});
expect(res.ok()).toBe(true);
const body = (await res.json()) as { id: string };
return body.id;
} finally {
await api.dispose();
}
}
async function createScriptViaApi(
appId: string,
name: string,
source = HELLO_RHAI
): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/scripts', {
data: { app_id: appId, name, source }
});
expect(res.ok()).toBe(true);
const body = (await res.json()) as { id: string };
return body.id;
} finally {
await api.dispose();
}
}
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
const cm = page.locator(locator).first();
await cm.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type(text);
}
test.describe('B3 scripts CRUD', () => {
test('create script via UI navigates to scripts list with the new entry', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('cscr');
await createAppViaApi(slug);
cleanup.app(slug);
await page.goto(`/admin/apps/${slug}`);
await page.getByRole('button', { name: /^New script$/ }).click();
await page.getByLabel('Name').fill('echo');
// The CodeMirror editor starts empty in create mode; type a
// minimal valid script.
await fillCodeMirror(page, '.cm-content', HELLO_RHAI);
await page.getByRole('button', { name: 'Create script' }).click();
await expect(page.getByRole('link', { name: /echo/i })).toBeVisible();
});
test('edit + save Rhai source persists across reload', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('edit');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'edit-target');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await expect(page.locator('.cm-content').first()).toContainText('statusCode');
const updated = `// edited by e2e\nreturn #{ statusCode: 201, body: #{ edited: true } };`;
await fillCodeMirror(page, '.cm-content', updated);
await page.getByRole('button', { name: /^Save$/ }).click();
// Save button becomes disabled once the buffer matches the
// just-saved source — that's our settle signal.
await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled();
await page.reload();
await expect(page.locator('.cm-content').first()).toContainText('edited by e2e');
});
test('invalid Rhai source: Format shows a parse error', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('invrhai');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'bad-syntax');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await fillCodeMirror(page, '.cm-content', 'this is not rhai @@@ {{{');
await page
.locator('.editor-header')
.getByRole('button', { name: 'Format' })
.click();
await expect(page.locator('.error.inline').first()).toBeVisible();
});
});
test.describe('B3 test-invoke', () => {
test('valid JSON body returns status + body in the result panel', async ({
page,
uniqueSlug
}) => {
const slug = uniqueSlug('inv-ok');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'invoke-ok');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
// Body editor is the second .cm-content (source is first).
const bodyEditor = page.locator('.cm-content').nth(1);
await bodyEditor.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type('{"hello":"world"}');
await page.getByRole('button', { name: /^Send$/ }).click();
await expect(page.locator('.status')).toContainText('HTTP 200');
await expect(page.locator('.result pre')).toContainText('ok');
});
test('malformed JSON body: Format surfaces the parse error', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('inv-bad');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'invoke-bad');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
const bodyEditor = page.locator('.cm-content').nth(1);
await bodyEditor.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Delete');
await page.keyboard.type('{not valid json,');
// The Format button for the request body sits inside the
// Test-invoke card next to the body editor.
await page
.locator('.json-block')
.first()
.getByRole('button', { name: 'Format' })
.click();
await expect(page.locator('.error.inline').first()).toBeVisible();
});
});
test.describe('B3 settings', () => {
test('timeout input rejects zero and non-positive values', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('settz');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(appId, 'settings-target');
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await page.getByRole('button', { name: 'Settings' }).click();
const timeout = page.getByLabel(/Timeout/);
await timeout.fill('0');
const invalid = await timeout.evaluate((el: HTMLInputElement) => !el.validity.valid);
expect(invalid).toBe(true);
});
});
test.describe('B3 adversarial', () => {
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('loop');
const appId = await createAppViaApi(slug);
const scriptId = await createScriptViaApi(
appId,
'inf-loop',
'loop { let x = 1; }'
);
cleanup.app(slug);
await page.goto(`/admin/scripts/${scriptId}`);
await page.getByRole('button', { name: /^Send$/ }).click();
// Either the status renders with a 5xx code, or an error
// banner shows up. Either way, the page recovers.
await Promise.race([
expect(page.locator('.status')).toBeVisible({ timeout: 30_000 }),
expect(page.locator('.error.inline').last()).toBeVisible({ timeout: 30_000 })
]);
// The dashboard must remain interactive after the timeout.
await page.getByRole('button', { name: 'Settings' }).click();
await expect(page.getByLabel(/Timeout/)).toBeVisible();
});
});

View File

@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
// Phase B8 — Cross-cutting security. Things that aren't tied to a
// single page: session handling, secret leakage, error states for
// missing resources, and a sanity check that no XSS sink fires
// anywhere in the dashboard's main authed routes.
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
test.describe('B8 cross-cutting security', () => {
test('expired/stale token: any authed call redirects to /login', async ({ page }) => {
// Replace the storageState token with an obvious garbage
// value; the fetch wrapper treats 401 as "go to /login".
await page.goto('/admin/login');
await page.evaluate(() => {
localStorage.setItem('picloud.admin.token', 'expired-or-bogus-token');
});
await page.goto('/admin/apps');
await expect(page).toHaveURL(/\/admin\/login$/);
});
test('login response cookie is HttpOnly', async ({ request }) => {
const res = await request.post('/api/v1/admin/auth/login', {
data: { username: VALID_USERNAME, password: VALID_PASSWORD },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
const headers = res.headers();
const setCookie = headers['set-cookie'];
// Backend may or may not set a cookie (the dashboard primarily
// uses bearer-in-localStorage). If it does, it must be
// HttpOnly so XSS can't exfiltrate it.
if (setCookie) {
expect(setCookie.toLowerCase()).toContain('httponly');
}
});
test('bootstrap password is not present in the DOM after login', async ({ page }) => {
await page.goto('/admin/apps');
const body = await page.locator('body').innerText();
expect(body).not.toContain(VALID_PASSWORD);
});
test('non-existent app slug shows a recoverable error, not a crash', async ({ page }) => {
await page.goto('/admin/apps/does-not-exist-e2e-9999');
// Page must render *something* and the layout must remain
// intact (header link to Apps still works).
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
// And surface the failure to the user — either a "couldn't
// load" message or a "back to apps" link.
const errorOrBack = page.locator('.error, a[href$="/admin/apps"]');
await expect(errorOrBack.first()).toBeVisible();
});
test('xss probe across major surfaces never fires a dialog', async ({ page }) => {
page.on('dialog', async (dialog) => {
await dialog.dismiss();
throw new Error(
`XSS sink fired — got a ${dialog.type()} dialog: "${dialog.message()}"`
);
});
// Cover each main authed route. None should evaluate any
// payload that earlier tests may have stored, and none should
// inject inline <script> tags from server responses.
for (const path of ['/admin/apps', '/admin/profile', '/admin/users']) {
await page.goto(path);
await page.waitForLoadState('domcontentloaded');
const inlineScripts = await page.locator('script[src=""], script:not([src])').count();
// Svelte itself injects no inline <script> in the
// production bundle; vite dev does, but never with
// onerror/alert payload text in them.
const evilInline = await page
.locator('script:has-text("alert"), script:has-text("__xss")')
.count();
expect(evilInline, `evil inline script tag on ${path}`).toBe(0);
expect(inlineScripts).toBeGreaterThanOrEqual(0); // sanity assertion, no crash
}
});
});

View File

@@ -0,0 +1,28 @@
import { expect, test } from '@playwright/test';
import { loginAsAdmin } from './fixtures/auth';
// A1 smoke: prove globalSetup + webServer + fixtures + proxy all work.
test.describe('smoke', () => {
test.describe('unauthenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('root redirects to login and shows the form', async ({ page }) => {
await page.goto('/admin/');
await expect(page).toHaveURL(/\/admin\/login$/);
await expect(page.getByLabel('Username')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
});
test('valid credentials land on the apps page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
});
});
test('admin storageState already lands on apps', async ({ page }) => {
await page.goto('/admin/');
await expect(page).toHaveURL(/\/admin\/apps$/);
});
});

View File

@@ -0,0 +1,209 @@
import { expect, type Browser, type Page, request } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
// Phase B6 — Instance Users (/admin/users). Covers the bootstrap
// admin's view of the user directory: invite, edit, deactivate,
// search, delete, plus the member-role redirect and adversarial
// inputs to the invite form.
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
});
async function createMember(username: string, password = 'e2e-member-pw'): Promise<string> {
const api = await adminApi();
try {
const res = await api.post('/api/v1/admin/admins', {
data: { username, password, instance_role: 'member' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { id: string }).id;
} finally {
await api.dispose();
}
}
async function loginToken(username: string, password: string): Promise<string> {
const ctx = await request.newContext({ baseURL: API_BASE });
try {
const res = await ctx.post('/api/v1/admin/auth/login', {
data: { username, password },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { token: string }).token;
} finally {
await ctx.dispose();
}
}
async function pageWithToken(browser: Browser, token: string): Promise<Page> {
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
await page.goto('/admin/login');
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
return page;
}
test.describe('B6 instance users', () => {
test('invite happy path: form → reveal modal → user in list', async ({
page,
uniqueUsername
}) => {
const username = uniqueUsername('inv');
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel('Username').fill(username);
await modal.getByRole('radio', { name: /^Member/ }).check();
await modal.getByRole('button', { name: /^Create user$/ }).click();
// Reveal modal shows the one-time password.
const reveal = page.locator('.reveal-modal');
await expect(reveal).toBeVisible();
await expect(reveal).toContainText(/User created — /);
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
await reveal.getByRole('checkbox', { name: /shared this/i }).check();
await reveal.getByRole('button', { name: /^Done$/ }).click();
// Now in the table.
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toBeVisible();
// API cleanup — we don't have the user id from the UI alone.
const api = await adminApi();
try {
const list = await api.get('/api/v1/admin/admins');
const all = (await list.json()) as Array<{ id: string; username: string }>;
const u = all.find((x) => x.username === username);
if (u) cleanup.adminUser(u.id);
} finally {
await api.dispose();
}
});
test('username live validation: bad chars → submit disabled', async ({ page }) => {
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel('Username').fill('UPPER_CASE_invalid');
await expect(modal.locator('small.invalid')).toContainText(/allowed pattern/i);
await modal.getByRole('radio', { name: /^Member/ }).check();
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
});
test('search filters the table by username', async ({ page, uniqueUsername }) => {
const target = uniqueUsername('hit');
const decoy = uniqueUsername('miss');
const ids = await Promise.all([createMember(target), createMember(decoy)]);
ids.forEach((id) => cleanup.adminUser(id));
await page.goto('/admin/users');
await page.getByPlaceholder(/Search by username/).fill(target);
await expect(page.locator('.row', { hasText: target })).toBeVisible();
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
});
test('deactivate then reactivate toggles the inactive indicator', async ({
page,
uniqueUsername
}) => {
const username = uniqueUsername('toggle');
const userId = await createMember(username);
cleanup.adminUser(userId);
await page.goto('/admin/users');
await page.getByPlaceholder(/Search by username/).fill(username);
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
await expect(row).toBeVisible();
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
await expect(row).toContainText(/inactive/i);
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
await expect(row).not.toContainText(/inactive/i);
});
test('delete: wrong phrase keeps disabled, right phrase removes the user', async ({
page,
uniqueUsername
}) => {
const username = uniqueUsername('del');
const userId = await createMember(username);
cleanup.adminUser(userId);
await page.goto('/admin/users');
await page.getByPlaceholder(/Search by username/).fill(username);
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Delete$/ }).click();
const dialog = page.getByRole('dialog');
const confirm = dialog.getByRole('button', { name: /^Delete user$/ });
await expect(confirm).toBeDisabled();
await dialog.getByRole('textbox').fill('not-the-username');
await expect(confirm).toBeDisabled();
await dialog.getByRole('textbox').fill(username);
await expect(confirm).toBeEnabled();
await confirm.click();
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toHaveCount(0);
});
test('member-role user visiting /admin/users is bounced to profile with denied banner', async ({
browser,
uniqueUsername
}) => {
const username = uniqueUsername('memvw');
const password = 'e2e-member-pw';
const userId = await createMember(username, password);
cleanup.adminUser(userId);
const token = await loginToken(username, password);
const memberPage = await pageWithToken(browser, token);
try {
await memberPage.goto('/admin/users');
await expect(memberPage).toHaveURL(/\/admin\/profile\?denied=users$/);
await expect(memberPage.getByText(/don.?t have access to the Users page/i)).toBeVisible();
} finally {
await memberPage.context().close();
}
});
});
test.describe('B6 instance users adversarial', () => {
test('username too short: live invalid + submit disabled', async ({ page }) => {
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel('Username').fill('a'); // 1 char — minimum is 2
await expect(modal.locator('small.invalid')).toBeVisible();
await modal.getByRole('radio', { name: /^Member/ }).check();
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
});
test('email with script tag fails validation, never executes', async ({ page }) => {
page.on('dialog', async (d) => {
await d.dismiss();
throw new Error(`Unexpected dialog: ${d.message()}`);
});
await page.goto('/admin/users');
await page.getByRole('button', { name: '+ Invite user' }).click();
const modal = page.locator('form.modal');
await modal.getByLabel(/Email/).fill('<script>alert(1)</script>@x');
await expect(modal.locator('small.invalid')).toContainText(/email/i);
});
});

View File

@@ -1,7 +1,7 @@
# Project Blueprint: Lightweight Event-Based Serverless Cloud
**Status**: Phase 4 — Blueprint Complete
**Last Updated**: 2026-04-10
**Last Updated**: 2026-05-27
**Audience**: Solo developer (DIY self-hosted)
---
@@ -1156,6 +1156,35 @@ DELETE /api/v1/admin/api-keys/{id} — caller's own only
Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity).
### App Member Management Endpoints
Exposes the `app_members` table as a first-class CRUD surface so app admins can manage who they share an app with from the dashboard, not just from SQL.
```
GET /api/v1/admin/apps/{id_or_slug}/members — list members (ordered by username),
joined with admin_users for
username / email / instance_role / is_active
POST /api/v1/admin/apps/{id_or_slug}/members — { user_id, role } → 201 enriched DTO
409 on duplicate (promotions go through PATCH)
422 if target user is_active = false
422 if target user instance_role != 'member'
(owners/admins have implicit authority;
an explicit row would be dead weight)
PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id} — { role } → 200 enriched DTO
404 if no existing membership
DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id} — 204 (idempotent — 204 also when missing)
```
All four are gated on `Capability::AppAdmin(app_id)`. Editors and viewers get 403 on list and never see the dashboard's Members tab.
**`my_role` on the app lookup endpoint.** `GET /api/v1/admin/apps/{id_or_slug}` now returns an additional `my_role: Option<AppRole>`, computed server-side from the principal: `Owner → app_admin`, `Admin → editor`, `Member → app_members.role`. The dashboard uses this single field to decide whether to render the Members tab (visible iff `my_role == app_admin`), keeping API and UI gate logic identical.
**No last-app-admin guard.** Unlike the last-owner protection on `admin_users`, removing the final `app_admin` row from `app_members` is allowed. Every `owner` instance-role user implicitly satisfies `Capability::AppAdmin(_)` via the top-level `role_grants` branch, so no app can become permanently orphaned — an owner can always re-issue grants. The `admin` instance role is only implicit *editor*, so it does **not** provide a fallback path; the owner guarantee alone is what makes the no-guard position safe.
**Dead-row sweep on promotion (deferred).** Promoting a user from `member``admin`/`owner` leaves their `app_members` rows in place. They become inert (implicit grants supersede), but are not auto-deleted. A future hook can sweep them; harmless for now.
Additive within `/api/v1/admin/...` — no API major bump per [docs/versioning.md](docs/versioning.md).
### Out of Scope (Phase 3.5)
Schema room only, not built:
@@ -1164,7 +1193,7 @@ Schema room only, not built:
- **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`.
- **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row.
Defer to follow-up sessions: dashboard surfaces for invites / member management / key minting (curl is the supported interface this phase), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
Defer to follow-up sessions: dashboard surfaces for invites / key minting (curl is the supported interface this phase — member management has a dashboard tab; see above), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
---