feat(interceptors): hook set_if (CAS) + pic interceptors ls (§9.4 M12)
Closes the §9.4 track:
- set_if (compare-and-swap), per-app and shared, now runs the same (kv, set)
before/after interceptor as set — otherwise CAS was a silent bypass of a set
policy. The before-hook can transform the new value; the after-hook sees the
swapped bool.
- read-only pic interceptors ls --app|--group: app shows the RESOLVED chain
(every marker guarding its writes, nearest-owner-wins), group its own markers.
New apply_service::interceptor_report + InterceptorInfo, /apps|groups/{id}/
interceptors routes (AppRead/GroupScriptsRead), client + cmd mirroring
extension-points.
CLAUDE.md updated: §9.4 service interceptors are now COMPLETE (M1-M12).
Pinned by a journey: set_if of a guarded key is denied while a free key swaps,
and interceptors ls lists the kv/set guard (12 interceptor journeys green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -215,12 +215,38 @@ fn register_set_if(engine: &mut RhaiEngine) {
|
|||||||
expected: Dynamic,
|
expected: Dynamic,
|
||||||
new: Dynamic|
|
new: Dynamic|
|
||||||
-> Result<bool, Box<EvalAltResult>> {
|
-> Result<bool, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
|
||||||
let exp = expected_from_dynamic(&expected)?;
|
let exp = expected_from_dynamic(&expected)?;
|
||||||
let new = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
let json = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
block_on("kv", async move {
|
// §9.4 M12: CAS is a WRITE, so it runs the `(kv, set)` guard too —
|
||||||
h.service.set_if(&h.cx, &h.collection, key, exp, new).await
|
// otherwise `set_if` would be a silent bypass of a `set` policy.
|
||||||
})
|
let write_val = super::interceptor::run_before(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"kv",
|
||||||
|
"set",
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
Some(&json),
|
||||||
|
)?
|
||||||
|
.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
|
let h = handle.clone();
|
||||||
|
let swapped = block_on("kv", async move {
|
||||||
|
h.service
|
||||||
|
.set_if(&h.cx, &h.collection, key, exp, write_val)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
super::interceptor::run_after(
|
||||||
|
&handle.ictx,
|
||||||
|
&handle.cx,
|
||||||
|
"kv",
|
||||||
|
"set",
|
||||||
|
&handle.collection,
|
||||||
|
key,
|
||||||
|
Some(&written),
|
||||||
|
serde_json::Value::Bool(swapped),
|
||||||
|
)?;
|
||||||
|
Ok(swapped)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -395,12 +421,25 @@ fn register_group_set_if(engine: &mut RhaiEngine) {
|
|||||||
expected: Dynamic,
|
expected: Dynamic,
|
||||||
new: Dynamic|
|
new: Dynamic|
|
||||||
-> Result<bool, Box<EvalAltResult>> {
|
-> Result<bool, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
|
||||||
let exp = expected_from_dynamic(&expected)?;
|
let exp = expected_from_dynamic(&expected)?;
|
||||||
let new = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
let json = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
block_on("kv", async move {
|
// §9.4 M12: CAS on a shared collection runs the `(kv, set)` guard too.
|
||||||
h.service.set_if(&h.cx, &h.collection, key, exp, new).await
|
let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
|
||||||
})
|
let written = write_val.clone();
|
||||||
|
let h = handle.clone();
|
||||||
|
let swapped = block_on("kv", async move {
|
||||||
|
h.service
|
||||||
|
.set_if(&h.cx, &h.collection, key, exp, write_val)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
group_run_after(
|
||||||
|
handle,
|
||||||
|
"set",
|
||||||
|
key,
|
||||||
|
Some(&written),
|
||||||
|
serde_json::Value::Bool(swapped),
|
||||||
|
)?;
|
||||||
|
Ok(swapped)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ use serde_json::json;
|
|||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::apply_service::{
|
use crate::apply_service::{
|
||||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
||||||
ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo,
|
ExtensionPointInfo, InterceptorInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl,
|
||||||
StructureMode, SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo,
|
RouteTemplateInfo, StructureMode, SuppressionInfo, TreeBundle, TreePlanResult,
|
||||||
|
TriggerTemplateInfo,
|
||||||
};
|
};
|
||||||
use crate::authz::{require, AuthzDenied, Capability};
|
use crate::authz::{require, AuthzDenied, Capability};
|
||||||
use crate::group_repo::GroupRepository;
|
use crate::group_repo::GroupRepository;
|
||||||
@@ -46,9 +47,45 @@ pub fn apply_router(service: ApplyService) -> Router {
|
|||||||
.route("/groups/{id}/routes", get(group_routes_handler))
|
.route("/groups/{id}/routes", get(group_routes_handler))
|
||||||
.route("/apps/{id}/suppressions", get(app_suppressions_handler))
|
.route("/apps/{id}/suppressions", get(app_suppressions_handler))
|
||||||
.route("/groups/{id}/suppressions", get(group_suppressions_handler))
|
.route("/groups/{id}/suppressions", get(group_suppressions_handler))
|
||||||
|
.route("/apps/{id}/interceptors", get(app_interceptors_handler))
|
||||||
|
.route("/groups/{id}/interceptors", get(group_interceptors_handler))
|
||||||
.with_state(service)
|
.with_state(service)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read-only §9.4 interceptor report for an app: the RESOLVED chain view (every
|
||||||
|
/// marker guarding its writes, nearest-owner-wins). Viewer-tier `AppRead`. Backs
|
||||||
|
/// `pic interceptors ls --app`.
|
||||||
|
async fn app_interceptors_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
|
||||||
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||||
|
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.interceptor_report(ApplyOwner::App(app_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only §9.4 interceptor report for a group: its OWN declared markers.
|
||||||
|
async fn group_interceptors_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let report = svc.interceptor_report(ApplyOwner::Group(group_id)).await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
/// Read-only §11 tail suppression report for an app: the inherited templates it
|
/// Read-only §11 tail suppression report for an app: the inherited templates it
|
||||||
/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs
|
/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs
|
||||||
/// `pic suppress ls --app`.
|
/// `pic suppress ls --app`.
|
||||||
|
|||||||
@@ -854,6 +854,19 @@ pub struct CollectionInfo {
|
|||||||
pub kind: String,
|
pub kind: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row of the read-only §9.4 interceptor report (`pic interceptors ls`).
|
||||||
|
/// For an app it is the RESOLVED chain view (markers visible on its ancestor
|
||||||
|
/// chain, nearest-owner-wins); for a group it is the group's OWN declarations.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InterceptorInfo {
|
||||||
|
pub service: String,
|
||||||
|
pub op: String,
|
||||||
|
pub phase: String,
|
||||||
|
pub script: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timeout_ms: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One row of the read-only §11 tail suppression report (`pic suppress ls
|
/// One row of the read-only §11 tail suppression report (`pic suppress ls
|
||||||
/// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler
|
/// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler
|
||||||
/// script name (trigger) or path (route) the app declines.
|
/// script name (trigger) or path (route) the app declines.
|
||||||
@@ -3671,6 +3684,32 @@ impl ApplyService {
|
|||||||
/// `inherited default` / `null` = unset). For a **group**: its own declared
|
/// `inherited default` / `null` = unset). For a **group**: its own declared
|
||||||
/// names (no single app to resolve a provider against). Backs the read-only
|
/// names (no single app to resolve a provider against). Backs the read-only
|
||||||
/// `extension-points ls` and the `pull` round-trip.
|
/// `extension-points ls` and the `pull` round-trip.
|
||||||
|
/// §9.4 read-only interceptor report. For an app: the RESOLVED chain view
|
||||||
|
/// (every marker visible on its ancestor chain, nearest-owner-wins) — what
|
||||||
|
/// actually guards its writes. For a group: its OWN declared markers.
|
||||||
|
pub async fn interceptor_report(
|
||||||
|
&self,
|
||||||
|
owner: ApplyOwner,
|
||||||
|
) -> Result<Vec<InterceptorInfo>, ApplyError> {
|
||||||
|
let markers = match owner {
|
||||||
|
ApplyOwner::App(a) => crate::interceptor_repo::list_on_app_chain(&self.pool, a).await,
|
||||||
|
ApplyOwner::Group(g) => {
|
||||||
|
crate::interceptor_repo::list_for_owner(&self.pool, ScriptOwner::Group(g)).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
Ok(markers
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| InterceptorInfo {
|
||||||
|
service: m.service,
|
||||||
|
op: m.op,
|
||||||
|
phase: m.phase,
|
||||||
|
script: m.script,
|
||||||
|
timeout_ms: m.timeout_ms,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn extension_point_report(
|
pub async fn extension_point_report(
|
||||||
&self,
|
&self,
|
||||||
owner: ApplyOwner,
|
owner: ApplyOwner,
|
||||||
|
|||||||
@@ -1608,6 +1608,18 @@ pub struct ExtensionPointInfoDto {
|
|||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row of the §9.4 interceptor report.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct InterceptorInfoDto {
|
||||||
|
pub service: String,
|
||||||
|
pub op: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub phase: String,
|
||||||
|
pub script: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout_ms: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
/// `GET /api/v1/admin/{apps|groups}/{ident}/extension-points`.
|
/// `GET /api/v1/admin/{apps|groups}/{ident}/extension-points`.
|
||||||
pub async fn extension_points_list(
|
pub async fn extension_points_list(
|
||||||
@@ -1626,6 +1638,24 @@ impl Client {
|
|||||||
decode(resp).await
|
decode(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §9.4: `GET /api/v1/admin/{apps|groups}/{ident}/interceptors`. For an app,
|
||||||
|
/// the resolved chain view (what guards its writes); for a group, its own.
|
||||||
|
pub async fn interceptors_list(
|
||||||
|
&self,
|
||||||
|
kind: NodeKind,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<Vec<InterceptorInfoDto>> {
|
||||||
|
let ident = seg(ident);
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/{}/{ident}/interceptors", kind.path()),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
/// `GET /api/v1/admin/groups/{ident}/collections` (§11.6). Group-only —
|
/// `GET /api/v1/admin/groups/{ident}/collections` (§11.6). Group-only —
|
||||||
/// shared collections are declared on groups.
|
/// shared collections are declared on groups.
|
||||||
pub async fn collections_list(&self, group_ident: &str) -> Result<Vec<CollectionInfoDto>> {
|
pub async fn collections_list(&self, group_ident: &str) -> Result<Vec<CollectionInfoDto>> {
|
||||||
|
|||||||
36
crates/picloud-cli/src/cmds/interceptors.rs
Normal file
36
crates/picloud-cli/src/cmds/interceptors.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//! `pic interceptors ls --app|--group` — read-only view of a node's §9.4
|
||||||
|
//! service interceptors. For an app, the RESOLVED chain view (every marker
|
||||||
|
//! guarding its writes, nearest-owner-wins — what actually runs); for a group,
|
||||||
|
//! its own declared markers. Authoring is declarative only (the manifest
|
||||||
|
//! `[[interceptors]]`).
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::client::{Client, NodeKind};
|
||||||
|
use crate::cmds::OwnerRef;
|
||||||
|
use crate::config;
|
||||||
|
use crate::output::{OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||||
|
let (kind, ident) = match crate::cmds::require_one_owner(app, group)? {
|
||||||
|
OwnerRef::App(a) => (NodeKind::App, a),
|
||||||
|
OwnerRef::Group(g) => (NodeKind::Group, g),
|
||||||
|
};
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let items = client.interceptors_list(kind, ident).await?;
|
||||||
|
|
||||||
|
let mut table = Table::new(["service", "op", "phase", "script", "timeout_ms"]);
|
||||||
|
for i in &items {
|
||||||
|
table.row([
|
||||||
|
i.service.clone(),
|
||||||
|
i.op.clone(),
|
||||||
|
i.phase.clone(),
|
||||||
|
i.script.clone(),
|
||||||
|
i.timeout_ms
|
||||||
|
.map_or_else(|| "-".to_string(), |t| t.to_string()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
table.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ pub mod extension_points;
|
|||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod groups;
|
pub mod groups;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
|
pub mod interceptors;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logout;
|
pub mod logout;
|
||||||
|
|||||||
@@ -176,6 +176,15 @@ enum Cmd {
|
|||||||
cmd: ExtensionPointsCmd,
|
cmd: ExtensionPointsCmd,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Service interceptors (§9.4) — read-only view of the before/after hooks
|
||||||
|
/// guarding a node's data-plane writes. Authored declaratively via the
|
||||||
|
/// manifest `[[interceptors]]`; `--app` shows the resolved chain (what
|
||||||
|
/// runs), `--group` the group's own declarations.
|
||||||
|
Interceptors {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: InterceptorsCmd,
|
||||||
|
},
|
||||||
|
|
||||||
/// Shared group collections (§11.6) — read-only view of the KV collection
|
/// Shared group collections (§11.6) — read-only view of the KV collection
|
||||||
/// names a group offers as cross-app-shared. Authored declaratively via the
|
/// names a group offers as cross-app-shared. Authored declaratively via the
|
||||||
/// `[group]` manifest `collections = [...]`; scripts read/write them with
|
/// `[group]` manifest `collections = [...]`; scripts read/write them with
|
||||||
@@ -661,6 +670,19 @@ enum ExtensionPointsCmd {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum InterceptorsCmd {
|
||||||
|
/// List a node's service interceptors. `--app` shows the resolved chain
|
||||||
|
/// (every marker guarding its writes, nearest-owner-wins); `--group` shows
|
||||||
|
/// the group's own declarations.
|
||||||
|
Ls {
|
||||||
|
#[arg(long)]
|
||||||
|
app: Option<String>,
|
||||||
|
#[arg(long, conflicts_with = "app")]
|
||||||
|
group: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum CollectionsCmd {
|
enum CollectionsCmd {
|
||||||
/// List the shared collections a group declares (§11.6). Group-only —
|
/// List the shared collections a group declares (§11.6). Group-only —
|
||||||
@@ -2204,6 +2226,9 @@ async fn main() -> ExitCode {
|
|||||||
Cmd::ExtensionPoints {
|
Cmd::ExtensionPoints {
|
||||||
cmd: ExtensionPointsCmd::Ls { app, group },
|
cmd: ExtensionPointsCmd::Ls { app, group },
|
||||||
} => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await,
|
} => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||||
|
Cmd::Interceptors {
|
||||||
|
cmd: InterceptorsCmd::Ls { app, group },
|
||||||
|
} => cmds::interceptors::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||||
Cmd::Collections {
|
Cmd::Collections {
|
||||||
cmd: CollectionsCmd::Ls { group },
|
cmd: CollectionsCmd::Ls { group },
|
||||||
} => cmds::collections::ls(&group, mode).await,
|
} => cmds::collections::ls(&group, mode).await,
|
||||||
|
|||||||
@@ -872,3 +872,71 @@ fn a_runaway_interceptor_is_denied_by_its_timeout() {
|
|||||||
"the timed-out write must NOT persist, got: {k}"
|
"the timed-out write must NOT persist, got: {k}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- §9.4 M12: set_if (CAS) is guarded + `pic interceptors ls` ------------
|
||||||
|
|
||||||
|
/// A writer that CAS-writes the guarded key (must be denied) and a free key
|
||||||
|
/// (must succeed) — proving `set_if` runs the `(kv, set)` guard, not a bypass.
|
||||||
|
const CAS_WRITER: &str = r#"
|
||||||
|
let denied = false;
|
||||||
|
try { kv::collection("c").set_if("secret", (), 1); } catch(e) { denied = true; }
|
||||||
|
let ok = kv::collection("c").set_if("free", (), 2);
|
||||||
|
#{ denied: denied, ok: ok }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// M12: `set_if` (compare-and-swap) is a write, so it runs the same `(kv, set)`
|
||||||
|
/// interceptor as `set` — otherwise CAS would be a silent bypass of a set policy.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn set_if_runs_the_set_interceptor() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let app = common::unique_slug("cas-app");
|
||||||
|
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &app])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let dir = manifest_dir();
|
||||||
|
fs::write(dir.path().join("scripts/guard.rhai"), GUARD).unwrap();
|
||||||
|
fs::write(dir.path().join("scripts/writer.rhai"), CAS_WRITER).unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("picloud.toml"),
|
||||||
|
format!(
|
||||||
|
"[app]\nslug = \"{app}\"\nname = \"CAS\"\n\n\
|
||||||
|
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
|
||||||
|
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
|
||||||
|
[[interceptors]]\nscript = \"guard\"\nops = [\"set\"]\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(dir.path().join("picloud.toml"))
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
serde_json::json!({ "denied": true, "ok": true }),
|
||||||
|
"set_if of the guarded key must be denied; a free key must swap"
|
||||||
|
);
|
||||||
|
|
||||||
|
// `pic interceptors ls --app` shows the resolved marker.
|
||||||
|
let ls = String::from_utf8(
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["interceptors", "ls", "--app", &app])
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
.stdout,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
ls.contains("kv") && ls.contains("set") && ls.contains("guard"),
|
||||||
|
"interceptors ls --app must list the kv/set guard:\n{ls}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user