From 59645e81596d899c7eca58ee545c0921ac2a4cab Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 10 Jun 2026 21:35:09 +0200 Subject: [PATCH] feat(cli): add pic routes + pic admins subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the audit's Critical "operator can't deploy a serverless endpoint end-to-end from the CLI" finding. Two new subcommand families bring the CLI coverage from 14 commands to ~25: - pic routes {ls, create, rm, check, match}: full route CRUD plus the dry-run conflict checker and the URL matcher already exposed by the dashboard. The create form accepts --path-kind, --host-kind, and --dispatch (sync|async) so async routes can finally be created headlessly. The ls output adds a dispatch column. - pic admins {ls, create, show, set, rm}: per-instance admin user management. Create reads passwords from stdin via --password - so shell history never sees the cleartext. Set is a JSON-Merge-Patch shape that lets operators deactivate accounts or rotate roles without touching the dashboard. Six new ignored integration tests follow the established #[ignore] pattern (DATABASE_URL gates them). They cover the happy-path round trip, the async-dispatch persistence path, the password-required error path, and the capability gate (a Member sees HTTP 403). All pass against the local dev stack with PICLOUD_DEV_MODE=true + PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure. The `pic members` and `pic domains` subcommands the audit mentioned are deferred — the apps_api shape may shift in v1.2 with per-app roles and rebuilding the CLI surface twice would be churn. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/picloud-cli/src/client.rs | 203 +++++++++++++- crates/picloud-cli/src/cmds/admins.rs | 181 +++++++++++++ crates/picloud-cli/src/cmds/mod.rs | 2 + crates/picloud-cli/src/cmds/routes.rs | 184 +++++++++++++ crates/picloud-cli/src/main.rs | 301 ++++++++++++++++++++- crates/picloud-cli/tests/admins.rs | 140 ++++++++++ crates/picloud-cli/tests/cli.rs | 2 + crates/picloud-cli/tests/common/cleanup.rs | 4 + crates/picloud-cli/tests/routes.rs | 162 +++++++++++ 9 files changed, 1177 insertions(+), 2 deletions(-) create mode 100644 crates/picloud-cli/src/cmds/admins.rs create mode 100644 crates/picloud-cli/src/cmds/routes.rs create mode 100644 crates/picloud-cli/tests/admins.rs create mode 100644 crates/picloud-cli/tests/routes.rs diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index ad6938b..d8d8271 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -10,7 +10,8 @@ use std::collections::BTreeMap; use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; use picloud_shared::{ - AdminUserId, ApiKeyId, App, AppId, AppRole, ExecutionLog, InstanceRole, Scope, Script, + AdminUserId, ApiKeyId, App, AppId, AppRole, DispatchMode, ExecutionLog, HostKind, InstanceRole, + PathKind, Route, Scope, Script, ScriptId, }; use reqwest::{header, Method, RequestBuilder, StatusCode}; use serde::{Deserialize, Serialize}; @@ -249,6 +250,113 @@ impl Client { .await?; decode_status(resp).await } + + /// `GET /api/v1/admin/scripts/{id}/routes` + pub async fn routes_list_for_script(&self, script_id: &str) -> Result> { + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/scripts/{script_id}/routes"), + ) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/scripts/{id}/routes` + pub async fn routes_create( + &self, + script_id: &str, + body: &CreateRouteBody<'_>, + ) -> Result { + let resp = self + .request( + Method::POST, + &format!("/api/v1/admin/scripts/{script_id}/routes"), + ) + .json(body) + .send() + .await?; + decode(resp).await + } + + /// `DELETE /api/v1/admin/routes/{route_id}` + pub async fn routes_delete(&self, route_id: &str) -> Result<()> { + let resp = self + .request(Method::DELETE, &format!("/api/v1/admin/routes/{route_id}")) + .send() + .await?; + decode_status(resp).await + } + + /// `POST /api/v1/admin/routes:check` — dry-run conflict check for a + /// hypothetical route, scoped to one app. + pub async fn routes_check(&self, body: &CheckRouteBody) -> Result { + let resp = self + .request(Method::POST, "/api/v1/admin/routes:check") + .json(body) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/routes:match` — what route would the given + /// URL+method match, if any, for the given app. + pub async fn routes_match(&self, body: &MatchRouteBody<'_>) -> Result { + let resp = self + .request(Method::POST, "/api/v1/admin/routes:match") + .json(body) + .send() + .await?; + decode(resp).await + } + + /// `GET /api/v1/admin/admins` + pub async fn admins_list(&self) -> Result> { + let resp = self + .request(Method::GET, "/api/v1/admin/admins") + .send() + .await?; + decode(resp).await + } + + /// `GET /api/v1/admin/admins/{id}` + pub async fn admins_get(&self, id: &str) -> Result { + let resp = self + .request(Method::GET, &format!("/api/v1/admin/admins/{id}")) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/admins` + pub async fn admins_create(&self, body: &CreateAdminBody<'_>) -> Result { + let resp = self + .request(Method::POST, "/api/v1/admin/admins") + .json(body) + .send() + .await?; + decode(resp).await + } + + /// `PATCH /api/v1/admin/admins/{id}` + pub async fn admins_patch(&self, id: &str, body: &PatchAdminBody<'_>) -> Result { + let resp = self + .request(Method::PATCH, &format!("/api/v1/admin/admins/{id}")) + .json(body) + .send() + .await?; + decode(resp).await + } + + /// `DELETE /api/v1/admin/admins/{id}` + pub async fn admins_delete(&self, id: &str) -> Result<()> { + let resp = self + .request(Method::DELETE, &format!("/api/v1/admin/admins/{id}")) + .send() + .await?; + decode_status(resp).await + } } /// `POST /api/v1/admin/auth/login` — sits outside the `Client` because @@ -304,6 +412,99 @@ pub struct CreateAppBody<'a> { pub description: Option<&'a str>, } +#[derive(Debug, Serialize)] +pub struct CreateRouteBody<'a> { + pub host_kind: HostKind, + #[serde(skip_serializing_if = "str::is_empty")] + pub host: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub host_param_name: Option<&'a str>, + pub path_kind: PathKind, + pub path: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option<&'a str>, + pub dispatch_mode: DispatchMode, +} + +#[derive(Debug, Serialize)] +pub struct CheckRouteBody { + pub app_id: AppId, + pub host_kind: HostKind, + #[serde(skip_serializing_if = "String::is_empty")] + pub host: String, + pub path_kind: PathKind, + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CheckRouteResponseDto { + pub ok: bool, + #[serde(default)] + pub conflicting_route: Option, + #[serde(default)] + pub conflict_reason: Option, +} + +#[derive(Debug, Serialize)] +pub struct MatchRouteBody<'a> { + pub app_id: AppId, + pub url: &'a str, + pub method: &'a str, +} + +#[derive(Debug, Deserialize)] +pub struct MatchRouteResponseDto { + #[serde(default)] + pub matched: Option, +} + +#[derive(Debug, Deserialize)] +pub struct MatchedRouteDto { + pub route_id: String, + pub script_id: ScriptId, + #[serde(default)] + pub params: BTreeMap, + #[serde(default)] + pub rest: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateAdminBody<'a> { + pub username: &'a str, + pub password: &'a str, + pub instance_role: InstanceRole, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option<&'a str>, +} + +#[derive(Debug, Default, Serialize)] +pub struct PatchAdminBody<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_active: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_role: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct AdminDto { + pub id: AdminUserId, + pub username: String, + pub is_active: bool, + pub instance_role: InstanceRole, + #[serde(default)] + pub email: Option, + pub created_at: DateTime, + #[serde(default)] + pub last_login_at: Option>, +} + #[derive(Debug, Serialize)] pub struct CreateScriptBody<'a> { pub app_id: AppId, diff --git a/crates/picloud-cli/src/cmds/admins.rs b/crates/picloud-cli/src/cmds/admins.rs new file mode 100644 index 0000000..94943d7 --- /dev/null +++ b/crates/picloud-cli/src/cmds/admins.rs @@ -0,0 +1,181 @@ +//! `pic admins` subcommands: `ls`, `create`, `show`, `set`, `rm`. +//! +//! All endpoints require `Capability::InstanceManageUsers` (owner / +//! admin). Member accounts get a 403. + +use std::io::{IsTerminal, Read, Write}; + +use anyhow::{anyhow, Context, Result}; +use picloud_shared::InstanceRole; + +use crate::client::{AdminDto, Client, CreateAdminBody, PatchAdminBody}; +use crate::config; +use crate::output::{KvBlock, OutputMode, Table}; + +pub async fn ls(mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let admins = client.admins_list().await?; + let mut table = Table::new([ + "id", + "username", + "role", + "active", + "email", + "created_at", + "last_login_at", + ]); + for a in admins { + table.row([ + a.id.to_string(), + a.username, + a.instance_role.as_str().to_string(), + a.is_active.to_string(), + a.email.unwrap_or_else(|| "-".into()), + a.created_at.to_rfc3339(), + a.last_login_at + .map(|t| t.to_rfc3339()) + .unwrap_or_else(|| "-".into()), + ]); + } + table.print(mode); + Ok(()) +} + +/// Resolve the password the user wants to set: +/// * `Some("-")` → read from stdin (no echo when interactive). +/// * `Some(other)` → use the inline value (handy for non-interactive +/// callers, but logs to shell history; warn when interactive). +/// * `None` → require explicit `--password`. +fn resolve_password(password_arg: Option<&str>) -> Result { + match password_arg { + None => Err(anyhow!( + "missing --password; pass --password - to read from stdin" + )), + Some("-") => read_password_from_stdin(), + Some(inline) => Ok(inline.to_string()), + } +} + +fn read_password_from_stdin() -> Result { + let stdin = std::io::stdin(); + if stdin.is_terminal() { + // Prompt without echoing — rpassword would be cleaner, but the + // CLI already avoids extra crates. Print a one-line prompt so + // the user knows we're waiting for input. + eprint!("Password: "); + std::io::stderr().flush().ok(); + let mut buf = String::new(); + stdin + .lock() + .read_line(&mut buf) + .context("read password from stdin")?; + Ok(buf.trim_end_matches(['\n', '\r']).to_string()) + } else { + // Piped input — read everything, strip trailing newline. + let mut buf = String::new(); + stdin + .lock() + .read_to_string(&mut buf) + .context("read password from stdin")?; + Ok(buf.trim_end_matches(['\n', '\r']).to_string()) + } +} + +trait ReadLine { + fn read_line(&mut self, buf: &mut String) -> std::io::Result; +} + +impl ReadLine for T { + fn read_line(&mut self, buf: &mut String) -> std::io::Result { + std::io::BufRead::read_line(self, buf) + } +} + +pub async fn create( + username: &str, + password: Option<&str>, + role: InstanceRole, + email: Option<&str>, + mode: OutputMode, +) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let pw = resolve_password(password)?; + if pw.is_empty() { + return Err(anyhow!("password must not be empty")); + } + let body = CreateAdminBody { + username, + password: &pw, + instance_role: role, + email, + }; + let created = client.admins_create(&body).await?; + print_admin(&created, mode); + Ok(()) +} + +pub async fn show(id: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let a = client.admins_get(id).await?; + print_admin(&a, mode); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub async fn set( + id: &str, + username: Option<&str>, + password: Option<&str>, + active: Option, + role: Option, + mode: OutputMode, +) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + // Only resolve the password if --password was passed. + let pw_owned; + let password_ref: Option<&str> = if let Some(raw) = password { + pw_owned = resolve_password(Some(raw))?; + Some(pw_owned.as_str()) + } else { + None + }; + let body = PatchAdminBody { + username, + password: password_ref, + is_active: active, + instance_role: role, + }; + let updated = client.admins_patch(id, &body).await?; + print_admin(&updated, mode); + Ok(()) +} + +pub async fn rm(id: &str) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + client.admins_delete(id).await?; + println!("Deleted admin {id}"); + Ok(()) +} + +fn print_admin(a: &AdminDto, mode: OutputMode) { + let mut block = KvBlock::new(); + block + .field("id", a.id.to_string()) + .field("username", a.username.clone()) + .field("instance_role", a.instance_role.as_str().to_string()) + .field("is_active", a.is_active.to_string()) + .field("email", a.email.clone().unwrap_or_else(|| "-".into())) + .field("created_at", a.created_at.to_rfc3339()) + .field( + "last_login_at", + a.last_login_at + .map(|t| t.to_rfc3339()) + .unwrap_or_else(|| "-".into()), + ); + block.print(mode); +} diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index 666957a..a47e78f 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -1,7 +1,9 @@ +pub mod admins; pub mod api_keys; pub mod apps; pub mod login; pub mod logout; pub mod logs; +pub mod routes; pub mod scripts; pub mod whoami; diff --git a/crates/picloud-cli/src/cmds/routes.rs b/crates/picloud-cli/src/cmds/routes.rs new file mode 100644 index 0000000..430a67b --- /dev/null +++ b/crates/picloud-cli/src/cmds/routes.rs @@ -0,0 +1,184 @@ +//! `pic routes` subcommands: `ls`, `create`, `rm`, `check`, `match`. + +use anyhow::{anyhow, Result}; +use picloud_shared::{AppId, DispatchMode, HostKind, PathKind}; + +use crate::client::{CheckRouteBody, Client, CreateRouteBody, MatchRouteBody}; +use crate::config; +use crate::output::{KvBlock, OutputMode, Table}; + +pub async fn ls(script_id: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let routes = client.routes_list_for_script(script_id).await?; + let mut table = Table::new([ + "id", + "method", + "host", + "path_kind", + "path", + "dispatch", + "created_at", + ]); + for r in routes { + table.row([ + r.id.to_string(), + r.method.clone().unwrap_or_else(|| "ANY".into()), + host_label(r.host_kind, &r.host), + path_kind_label(r.path_kind).to_string(), + r.path.clone(), + dispatch_label(r.dispatch_mode).to_string(), + r.created_at.to_rfc3339(), + ]); + } + table.print(mode); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub async fn create( + script_id: &str, + method: Option<&str>, + path: &str, + path_kind: PathKind, + host: &str, + host_kind: HostKind, + host_param_name: Option<&str>, + dispatch_mode: DispatchMode, +) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let body = CreateRouteBody { + host_kind, + host, + host_param_name, + path_kind, + path, + method, + dispatch_mode, + }; + let r = client.routes_create(script_id, &body).await?; + println!( + "Created route {} ({} {} {})", + r.id, + r.method.unwrap_or_else(|| "ANY".into()), + host_label(r.host_kind, &r.host), + r.path + ); + Ok(()) +} + +pub async fn rm(route_id: &str) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + client.routes_delete(route_id).await?; + println!("Deleted route {route_id}"); + Ok(()) +} + +pub async fn check( + app: &str, + method: Option<&str>, + path: &str, + path_kind: PathKind, + host: &str, + host_kind: HostKind, + mode: OutputMode, +) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let app_id = resolve_app_id(&client, app).await?; + let body = CheckRouteBody { + app_id, + host_kind, + host: host.to_string(), + path_kind, + path: path.to_string(), + method: method.map(str::to_string), + }; + let resp = client.routes_check(&body).await?; + let mut block = KvBlock::new(); + block.field("ok", resp.ok.to_string()); + if let Some(reason) = resp.conflict_reason { + block.field("conflict_reason", reason); + } + if let Some(r) = resp.conflicting_route { + block + .field("conflicting_route_id", r.id.to_string()) + .field( + "conflicting_method", + r.method.unwrap_or_else(|| "ANY".into()), + ) + .field("conflicting_host", host_label(r.host_kind, &r.host)) + .field("conflicting_path", r.path); + } + block.print(mode); + Ok(()) +} + +pub async fn match_route(app: &str, url: &str, method: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let app_id = resolve_app_id(&client, app).await?; + let resp = client + .routes_match(&MatchRouteBody { + app_id, + url, + method, + }) + .await?; + let mut block = KvBlock::new(); + match resp.matched { + None => { + block.field("matched", "false"); + } + Some(m) => { + block + .field("matched", "true") + .field("route_id", m.route_id) + .field("script_id", m.script_id.to_string()); + if !m.params.is_empty() { + for (k, v) in m.params { + block.field(format!("param.{k}"), v); + } + } + if let Some(rest) = m.rest { + block.field("rest", rest); + } + } + } + block.print(mode); + Ok(()) +} + +async fn resolve_app_id(client: &Client, ident: &str) -> Result { + // Accept either a slug or a UUID. The apps_get endpoint takes both. + let lookup = client + .apps_get(ident) + .await + .map_err(|e| anyhow!("resolve app `{ident}`: {e}"))?; + Ok(lookup.app.id) +} + +const fn dispatch_label(d: DispatchMode) -> &'static str { + match d { + DispatchMode::Sync => "sync", + DispatchMode::Async => "async", + } +} + +const fn path_kind_label(k: PathKind) -> &'static str { + match k { + PathKind::Exact => "exact", + PathKind::Prefix => "prefix", + PathKind::Param => "param", + } +} + +fn host_label(kind: HostKind, host: &str) -> String { + match kind { + HostKind::Any => "*".to_string(), + HostKind::Strict => host.to_string(), + HostKind::Wildcard => format!("*.{host}"), + } +} diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index d84975e..47e10c4 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use std::process::ExitCode; -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; mod client; mod cmds; @@ -70,6 +70,20 @@ enum Cmd { /// Top-level alias for `pic scripts deploy --app `. Deploy(DeployArgs), + + /// Route management — bind script ids to HTTP method+host+path. + Routes { + #[command(subcommand)] + cmd: RoutesCmd, + }, + + /// Admin user management — list / create / patch / delete the + /// per-instance admin accounts. All endpoints require + /// `instance:admin` or `instance:owner`. + Admins { + #[command(subcommand)] + cmd: AdminsCmd, + }, } #[derive(Args)] @@ -185,6 +199,190 @@ struct LogsArgs { limit: u32, } +#[derive(Clone, Copy, ValueEnum)] +enum DispatchModeArg { + Sync, + Async, +} + +impl From for picloud_shared::DispatchMode { + fn from(v: DispatchModeArg) -> Self { + match v { + DispatchModeArg::Sync => Self::Sync, + DispatchModeArg::Async => Self::Async, + } + } +} + +#[derive(Clone, Copy, ValueEnum)] +enum HostKindArg { + /// Any host (default). + Any, + /// Exact `host` only. + Strict, + /// `*.` — wildcard subdomain. + Wildcard, +} + +impl From for picloud_shared::HostKind { + fn from(v: HostKindArg) -> Self { + match v { + HostKindArg::Any => Self::Any, + HostKindArg::Strict => Self::Strict, + HostKindArg::Wildcard => Self::Wildcard, + } + } +} + +#[derive(Clone, Copy, ValueEnum)] +enum PathKindArg { + Exact, + Prefix, + Param, +} + +impl From for picloud_shared::PathKind { + fn from(v: PathKindArg) -> Self { + match v { + PathKindArg::Exact => Self::Exact, + PathKindArg::Prefix => Self::Prefix, + PathKindArg::Param => Self::Param, + } + } +} + +#[derive(Clone, Copy, ValueEnum)] +enum InstanceRoleArg { + Owner, + Admin, + Member, +} + +impl From for picloud_shared::InstanceRole { + fn from(v: InstanceRoleArg) -> Self { + match v { + InstanceRoleArg::Owner => Self::Owner, + InstanceRoleArg::Admin => Self::Admin, + InstanceRoleArg::Member => Self::Member, + } + } +} + +#[derive(Subcommand)] +enum RoutesCmd { + /// List routes bound to a script. + Ls { script_id: String }, + + /// Create a new route. Host defaults to `*` (any). Path-kind + /// defaults to `exact`. Dispatch defaults to `sync`. + Create { + /// The script to bind. Routes inherit the script's `app_id`. + #[arg(long)] + script: String, + /// HTTP path (e.g. `/webhook`, `/users/:id`, `/blobs/*`). + #[arg(long)] + path: String, + /// HTTP method to match. Omit for ANY. + #[arg(long)] + method: Option, + /// `exact`, `prefix`, or `param`. + #[arg(long = "path-kind", value_enum, default_value_t = PathKindArg::Exact)] + path_kind: PathKindArg, + /// Host pattern (e.g. `*`, `api.example.com`, `*.example.com`). + /// Pair with `--host-kind` when ambiguous; the CLI does not + /// auto-detect. + #[arg(long, default_value = "")] + host: String, + /// `any` (default), `strict`, or `wildcard`. + #[arg(long = "host-kind", value_enum, default_value_t = HostKindArg::Any)] + host_kind: HostKindArg, + /// Name to bind the host-wildcard segment to (for + /// `host-kind=wildcard` routes that want to read the subdomain). + #[arg(long = "host-param-name")] + host_param_name: Option, + /// `sync` (default) or `async`. Async returns 202 immediately; + /// the dispatcher fires the script in the background. + #[arg(long, value_enum, default_value_t = DispatchModeArg::Sync)] + dispatch: DispatchModeArg, + }, + + /// Delete a route by id. + Rm { route_id: String }, + + /// Dry-run conflict check for a hypothetical route. + Check { + /// App slug or UUID (conflict checks are intra-app). + #[arg(long)] + app: String, + #[arg(long)] + path: String, + #[arg(long)] + method: Option, + #[arg(long = "path-kind", value_enum, default_value_t = PathKindArg::Exact)] + path_kind: PathKindArg, + #[arg(long, default_value = "")] + host: String, + #[arg(long = "host-kind", value_enum, default_value_t = HostKindArg::Any)] + host_kind: HostKindArg, + }, + + /// What route would the given URL+method match, if any? + Match { + /// App slug or UUID. + #[arg(long)] + app: String, + /// Full URL — host+path must be present. + url: String, + #[arg(long, default_value = "GET")] + method: String, + }, +} + +#[derive(Subcommand)] +enum AdminsCmd { + /// List admin accounts. + Ls, + + /// Create a new admin. Password via `--password -` reads from + /// stdin (the recommended path for non-interactive use). Without + /// `--instance-role`, defaults to `admin`. + Create { + username: String, + /// Password. `-` reads from stdin (one line interactive, or the + /// full input piped). Passing the password inline puts it in + /// shell history — pipe instead when scripting. + #[arg(long)] + password: Option, + /// `owner`, `admin`, or `member`. Defaults to `admin`. + #[arg(long = "instance-role", value_enum, default_value_t = InstanceRoleArg::Admin)] + instance_role: InstanceRoleArg, + #[arg(long)] + email: Option, + }, + + /// Show a single admin by id. + Show { id: String }, + + /// Patch an admin. Pass only the fields you want to change. + Set { + id: String, + #[arg(long)] + username: Option, + /// `-` reads from stdin. Inline value lands in shell history. + #[arg(long)] + password: Option, + /// `true` to (re)activate, `false` to deactivate (deactivation + /// also expires every API key owned by the user). + #[arg(long)] + active: Option, + #[arg(long = "instance-role", value_enum)] + instance_role: Option, + }, + + /// Delete an admin by id. + Rm { id: String }, +} + #[tokio::main(flavor = "current_thread")] async fn main() -> ExitCode { let cli = Cli::parse(); @@ -256,6 +454,107 @@ async fn main() -> ExitCode { ) .await } + Cmd::Routes { + cmd: RoutesCmd::Ls { script_id }, + } => cmds::routes::ls(&script_id, mode).await, + Cmd::Routes { + cmd: + RoutesCmd::Create { + script, + path, + method, + path_kind, + host, + host_kind, + host_param_name, + dispatch, + }, + } => { + cmds::routes::create( + &script, + method.as_deref(), + &path, + path_kind.into(), + &host, + host_kind.into(), + host_param_name.as_deref(), + dispatch.into(), + ) + .await + } + Cmd::Routes { + cmd: RoutesCmd::Rm { route_id }, + } => cmds::routes::rm(&route_id).await, + Cmd::Routes { + cmd: + RoutesCmd::Check { + app, + path, + method, + path_kind, + host, + host_kind, + }, + } => { + cmds::routes::check( + &app, + method.as_deref(), + &path, + path_kind.into(), + &host, + host_kind.into(), + mode, + ) + .await + } + Cmd::Routes { + cmd: RoutesCmd::Match { app, url, method }, + } => cmds::routes::match_route(&app, &url, &method, mode).await, + Cmd::Admins { cmd: AdminsCmd::Ls } => cmds::admins::ls(mode).await, + Cmd::Admins { + cmd: + AdminsCmd::Create { + username, + password, + instance_role, + email, + }, + } => { + cmds::admins::create( + &username, + password.as_deref(), + instance_role.into(), + email.as_deref(), + mode, + ) + .await + } + Cmd::Admins { + cmd: AdminsCmd::Show { id }, + } => cmds::admins::show(&id, mode).await, + Cmd::Admins { + cmd: + AdminsCmd::Set { + id, + username, + password, + active, + instance_role, + }, + } => { + cmds::admins::set( + &id, + username.as_deref(), + password.as_deref(), + active, + instance_role.map(Into::into), + mode, + ) + .await + } + Cmd::Admins { + cmd: AdminsCmd::Rm { id }, + } => cmds::admins::rm(&id).await, }; match result { diff --git a/crates/picloud-cli/tests/admins.rs b/crates/picloud-cli/tests/admins.rs new file mode 100644 index 0000000..c8cb5a8 --- /dev/null +++ b/crates/picloud-cli/tests/admins.rs @@ -0,0 +1,140 @@ +//! `pic admins` smoke tests. Verifies the CRUD round trip and the +//! capability gate (a Member account can't list/create admins). + +use predicates::prelude::*; + +use crate::common; +use crate::common::cleanup::UserGuard; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn create_show_list_patch_remove_round_trip() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let username = common::unique_username("rtadm"); + + // Create — password piped via stdin. + let out = common::pic_as(&env) + .args([ + "admins", + "create", + &username, + "--password", + "-", + "--instance-role", + "admin", + "--email", + "admin@example.test", + ]) + .write_stdin("pw-from-stdin\n") + .output() + .expect("admins create"); + assert!(out.status.success(), "admins create failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let id = stdout + .lines() + .find_map(|l| l.strip_prefix("id")) + .map(|s| s.trim().to_string()) + .expect("id field in admins create output"); + let _guard = UserGuard::new(&env.url, &env.token, &id); + + // List — our new admin appears with the right role. + let out = common::pic_as(&env) + .args(["admins", "ls"]) + .output() + .expect("admins ls"); + assert!(out.status.success(), "admins ls failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let header = stdout.lines().next().expect("header"); + assert_eq!( + common::cells(header), + vec![ + "id", + "username", + "role", + "active", + "email", + "created_at", + "last_login_at" + ] + ); + let row = stdout + .lines() + .skip(1) + .map(common::cells) + .find(|c| c.get(1).copied() == Some(username.as_str())) + .unwrap_or_else(|| panic!("{username} missing from admins ls: {stdout}")); + assert_eq!(row[2], "admin"); + assert_eq!(row[3], "true"); + + // Show — KvBlock includes the username row. + common::pic_as(&env) + .args(["admins", "show", &id]) + .assert() + .success() + .stdout(predicate::str::contains(&username)); + + // Patch — deactivate. + common::pic_as(&env) + .args(["admins", "set", &id, "--active", "false"]) + .assert() + .success(); + + // Patch result echoes is_active=false. + let out = common::pic_as(&env) + .args(["admins", "show", &id]) + .output() + .expect("admins show after patch"); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout + .lines() + .any(|l| l.starts_with("is_active") && l.trim_end().ends_with("false")), + "patch should have deactivated: {stdout}" + ); + + // Remove. + common::pic_as(&env) + .args(["admins", "rm", &id]) + .assert() + .success() + .stdout(predicate::str::contains(format!("Deleted admin {id}"))); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn create_without_password_errors() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let username = common::unique_username("nopw"); + + common::pic_as(&env) + .args(["admins", "create", &username]) + .assert() + .failure() + .stderr(predicate::str::contains("missing --password")); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn member_cannot_list_admins() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let admin_env = common::admin_env(fx); + let m = common::member::member_user(fx, &common::unique_username("nomgmt")); + let member_env = common::custom_env(&fx.url, &m.token); + common::seed_credentials(&member_env, &m.username); + + common::pic_as(&member_env) + .args(["admins", "ls"]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 403")); + // Belt and suspenders so the unused `admin_env` doesn't warn. + let _ = admin_env; +} diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 541c6f3..7092b05 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -13,6 +13,7 @@ mod common; +mod admins; mod api_keys; mod apps; mod auth; @@ -20,4 +21,5 @@ mod invoke; mod logs; mod output; mod roles; +mod routes; mod scripts; diff --git a/crates/picloud-cli/tests/common/cleanup.rs b/crates/picloud-cli/tests/common/cleanup.rs index 9a2783d..5e4992d 100644 --- a/crates/picloud-cli/tests/common/cleanup.rs +++ b/crates/picloud-cli/tests/common/cleanup.rs @@ -19,6 +19,10 @@ impl AppGuard { slug: slug.to_string(), } } + + pub fn slug(&self) -> &str { + &self.slug + } } impl Drop for AppGuard { diff --git a/crates/picloud-cli/tests/routes.rs b/crates/picloud-cli/tests/routes.rs new file mode 100644 index 0000000..8df3545 --- /dev/null +++ b/crates/picloud-cli/tests/routes.rs @@ -0,0 +1,162 @@ +//! `pic routes` smoke + edge tests. Covers the create → ls → match → +//! rm round trip and the validation surface (unknown script id, +//! conflict on duplicate). + +use predicates::prelude::*; + +use crate::common; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn create_ls_match_remove_round_trip() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (script_id, guard) = common::deploy_fixture(&env, "routes-rt", "hello.rhai"); + let app_slug = guard.slug().to_string(); + + // Create — exact path, any method, sync dispatch by default. + common::pic_as(&env) + .args([ + "routes", "create", "--script", &script_id, "--path", "/hook", "--method", "POST", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Created route")); + + // Ls — header columns and our row present. + let out = common::pic_as(&env) + .args(["routes", "ls", &script_id]) + .output() + .expect("routes ls"); + assert!(out.status.success(), "routes ls failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let mut lines = stdout.lines(); + let header = lines.next().expect("header row"); + assert_eq!( + common::cells(header), + vec![ + "id", + "method", + "host", + "path_kind", + "path", + "dispatch", + "created_at" + ] + ); + let row = lines + .map(common::cells) + .find(|c| c.get(4).copied() == Some("/hook")) + .unwrap_or_else(|| panic!("/hook row not in routes ls output: {stdout}")); + assert_eq!(row[1], "POST"); + assert_eq!(row[5], "sync"); + let route_id = row[0].to_string(); + + // Match — POST /hook on the app's any-host space resolves. + let out = common::pic_as(&env) + .args([ + "routes", + "match", + "--app", + &app_slug, + "--method", + "POST", + "http://localhost/hook", + ]) + .output() + .expect("routes match"); + assert!(out.status.success(), "routes match failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout.contains("matched") && stdout.contains("true"), + "match should have hit: {stdout}" + ); + assert!( + stdout.contains(&route_id), + "match output should name the route id: {stdout}" + ); + + // Rm — drops the row. + common::pic_as(&env) + .args(["routes", "rm", &route_id]) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "Deleted route {route_id}" + ))); + + let out = common::pic_as(&env) + .args(["routes", "ls", &script_id]) + .output() + .expect("routes ls after rm"); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + !stdout.lines().skip(1).any(|l| l.contains(&route_id)), + "deleted route still in ls: {stdout}" + ); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn create_async_dispatch_is_persisted() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (script_id, _guard) = common::deploy_fixture(&env, "routes-async", "hello.rhai"); + + common::pic_as(&env) + .args([ + "routes", + "create", + "--script", + &script_id, + "--path", + "/bg", + "--method", + "POST", + "--dispatch", + "async", + ]) + .assert() + .success(); + + let out = common::pic_as(&env) + .args(["routes", "ls", &script_id]) + .output() + .expect("routes ls"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let row = stdout + .lines() + .skip(1) + .map(common::cells) + .find(|c| c.get(4).copied() == Some("/bg")) + .unwrap_or_else(|| panic!("/bg row missing: {stdout}")); + assert_eq!(row[5], "async", "dispatch column should be async: {row:?}"); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn create_against_unknown_script_404s() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + // Bogus UUID-shaped script id so the path matcher accepts it + // but the script lookup 404s. + common::pic_as(&env) + .args([ + "routes", + "create", + "--script", + "00000000-0000-0000-0000-000000000000", + "--path", + "/x", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("HTTP 404")); +}