chore(v1.1.5): version bumps, CI workflow, schema-snapshot un-ignore

- Workspace 1.1.4 → 1.1.5; SDK 1.5 → 1.6; dashboard 0.10.0 → 0.11.0.
- CHANGELOG v1.1.5 entry; CLAUDE.md runtime-config table gains
  PICLOUD_FILES_ROOT + PICLOUD_FILES_MAX_FILE_SIZE_BYTES.
- schema_snapshot test: drop #[ignore] + #[sqlx::test]; run against
  DATABASE_URL when set, skip cleanly when absent. Re-blessed golden
  picks up files / files_trigger_details / pubsub_trigger_details, the
  two widened CHECKs, and the pubsub partial index.
- First CI workflow (.github/workflows/ci.yml): postgres:15 service +
  fmt + clippy + cargo test --workspace; separate dashboard check job.
- Add files/pubsub admin-trigger reject-coverage tests (module +
  cross-app + bad-pattern), mirroring the v1.1.3 regression set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-03 21:44:12 +02:00
parent 834c787ee1
commit 4595db7a7a
10 changed files with 499 additions and 20 deletions

View File

@@ -1739,4 +1739,258 @@ mod tests {
.expect("endpoint target should succeed");
assert_eq!(status, StatusCode::CREATED);
}
// ----------------------------------------------------------------
// v1.1.5: files + pubsub trigger create (Layout-E reject coverage).
// ----------------------------------------------------------------
fn files_req(script_id: ScriptId, glob: &str) -> CreateFilesTriggerRequest {
CreateFilesTriggerRequest {
script_id,
collection_glob: glob.into(),
ops: vec![FilesEventOp::Create],
dispatch_mode: TriggerDispatchMode::Async,
retry_max_attempts: None,
retry_backoff: None,
retry_base_ms: None,
}
}
#[tokio::test]
async fn files_trigger_create_succeeds() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
let (status, Json(trigger)) = create_files_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(files_req(script_id, "avatars")),
)
.await
.unwrap();
assert_eq!(status, StatusCode::CREATED);
assert!(matches!(
trigger.kind,
crate::trigger_repo::TriggerKind::Files
));
match trigger.details {
TriggerDetails::Files {
collection_glob,
ops,
} => {
assert_eq!(collection_glob, "avatars");
assert_eq!(ops, vec![FilesEventOp::Create]);
}
other => panic!("expected Files details, got {other:?}"),
}
}
#[tokio::test]
async fn files_trigger_empty_glob_rejected() {
let app_id = AppId::new();
let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
let res = create_files_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(files_req(ScriptId::new(), " ")),
)
.await;
assert!(matches!(
res.expect_err("empty glob"),
TriggersApiError::Invalid(_)
));
}
#[tokio::test]
async fn files_trigger_rejects_module_target() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = TriggersState {
triggers: Arc::new(InMemoryTriggerRepo::default()),
apps: InMemoryAppRepo::with(app_id),
authz: Arc::new(AlwaysAllowAuthzRepo),
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
config: TriggerConfig::conservative(),
};
let res = create_files_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(files_req(script_id, "avatars")),
)
.await;
let msg = match res.expect_err("module rejected") {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("module"));
}
#[tokio::test]
async fn files_trigger_rejects_cross_app_script() {
let app_a = AppId::new();
let app_b = AppId::new();
let script_id = ScriptId::new();
let state = TriggersState {
triggers: Arc::new(InMemoryTriggerRepo::default()),
apps: InMemoryAppRepo::with(app_a),
authz: Arc::new(AlwaysAllowAuthzRepo),
scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
config: TriggerConfig::conservative(),
};
let res = create_files_trigger(
State(state),
Extension(member_principal()),
Path(app_a),
Json(files_req(script_id, "avatars")),
)
.await;
let msg = match res.expect_err("cross-app rejected") {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("does not belong"));
}
#[tokio::test]
async fn files_trigger_member_without_role_is_forbidden() {
let app_id = AppId::new();
let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
let res = create_files_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(files_req(ScriptId::new(), "avatars")),
)
.await;
assert!(matches!(
res.expect_err("forbidden"),
TriggersApiError::Forbidden
));
}
fn pubsub_req(script_id: ScriptId, pattern: &str) -> CreatePubsubTriggerRequest {
CreatePubsubTriggerRequest {
script_id,
topic_pattern: pattern.into(),
dispatch_mode: TriggerDispatchMode::Async,
retry_max_attempts: None,
retry_backoff: None,
retry_base_ms: None,
}
}
#[tokio::test]
async fn pubsub_trigger_create_succeeds() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
let (status, Json(trigger)) = create_pubsub_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(pubsub_req(script_id, "user.*")),
)
.await
.unwrap();
assert_eq!(status, StatusCode::CREATED);
match trigger.details {
TriggerDetails::Pubsub { topic_pattern } => assert_eq!(topic_pattern, "user.*"),
other => panic!("expected Pubsub details, got {other:?}"),
}
}
#[tokio::test]
async fn pubsub_trigger_rejects_bad_pattern() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = state_with_endpoint(Arc::new(AlwaysAllowAuthzRepo), app_id, script_id);
for bad in ["*.created", "a.*.b", "**"] {
let res = create_pubsub_trigger(
State(state.clone()),
Extension(member_principal()),
Path(app_id),
Json(pubsub_req(script_id, bad)),
)
.await;
let msg = match res.expect_err("bad pattern") {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(
msg.contains("unsupported pubsub topic pattern"),
"got {msg} for {bad}"
);
}
}
#[tokio::test]
async fn pubsub_trigger_rejects_module_target() {
let app_id = AppId::new();
let script_id = ScriptId::new();
let state = TriggersState {
triggers: Arc::new(InMemoryTriggerRepo::default()),
apps: InMemoryAppRepo::with(app_id),
authz: Arc::new(AlwaysAllowAuthzRepo),
scripts: InMemoryScriptRepo::with_module(app_id, script_id),
config: TriggerConfig::conservative(),
};
let res = create_pubsub_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(pubsub_req(script_id, "user.*")),
)
.await;
let msg = match res.expect_err("module rejected") {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("module"));
}
#[tokio::test]
async fn pubsub_trigger_rejects_cross_app_script() {
let app_a = AppId::new();
let app_b = AppId::new();
let script_id = ScriptId::new();
let state = TriggersState {
triggers: Arc::new(InMemoryTriggerRepo::default()),
apps: InMemoryAppRepo::with(app_a),
authz: Arc::new(AlwaysAllowAuthzRepo),
scripts: InMemoryScriptRepo::with_endpoint(app_b, script_id),
config: TriggerConfig::conservative(),
};
let res = create_pubsub_trigger(
State(state),
Extension(member_principal()),
Path(app_a),
Json(pubsub_req(script_id, "user.*")),
)
.await;
let msg = match res.expect_err("cross-app rejected") {
TriggersApiError::Invalid(m) => m,
other => panic!("expected Invalid, got {other:?}"),
};
assert!(msg.to_lowercase().contains("does not belong"));
}
#[tokio::test]
async fn pubsub_trigger_member_without_role_is_forbidden() {
let app_id = AppId::new();
let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
let res = create_pubsub_trigger(
State(state),
Extension(member_principal()),
Path(app_id),
Json(pubsub_req(ScriptId::new(), "user.*")),
)
.await;
assert!(matches!(
res.expect_err("forbidden"),
TriggersApiError::Forbidden
));
}
}