Initialize self-scoped Subject conversation permissions and publish async chat profile
This commit is contained in:
@@ -1751,7 +1751,7 @@ mod tests {
|
||||
assert!(feature.subjektiv.profile.enabled);
|
||||
assert!(feature.subjektiv.profile.chat.enabled);
|
||||
assert!(!feature.subjektiv.profile.consolidation_tools);
|
||||
assert!(!feature.subjektiv.profile.extraction.enabled);
|
||||
assert!(feature.subjektiv.profile.extraction.enabled);
|
||||
assert!(feature.subjektiv.workspace_settings.is_none());
|
||||
assert!(!feature.subjektiv.execution_enabled());
|
||||
assert!(!feature.memory.profile.enabled);
|
||||
@@ -1823,6 +1823,8 @@ mod tests {
|
||||
"SubjektivMemoryQuery",
|
||||
"SubjektivMemoryRead",
|
||||
"SubjektivMemoryListChanges",
|
||||
"SubjektivMemoryRemember",
|
||||
"SubjektivMemoryProposeChange",
|
||||
"QueryObjective",
|
||||
"ShowObjective",
|
||||
"QueryTicket",
|
||||
|
||||
@@ -55,6 +55,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod catalog;
|
||||
pub use catalog::*;
|
||||
mod subject_defaults;
|
||||
pub use subject_defaults::*;
|
||||
|
||||
pub const MAX_SCOPED_PERMISSIONS: usize = 256;
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Initial conversation authority, not a runtime fallback or a Profile capability.
|
||||
//!
|
||||
//! Hosts persist this set only while creating a new Subject. Existing Subjects opt in
|
||||
//! with the normal administrator CAS replacement: read the current source, merge only
|
||||
//! the desired entries into `execution`, preserve `delegation`, and submit that observed
|
||||
//! change ID with a stable operation ID. Never apply it on launch, restore or migration;
|
||||
//! an unset or explicitly emptied existing policy must remain denied. The Subject detail
|
||||
//! permissions editor and `GET`/`POST /api/w/{workspace}/subjektiv/subjects/{subject}/permissions`
|
||||
//! expose the same saved source and CAS. Replacing a source also invalidates previously
|
||||
//! delegated children; an administrator must deliberately review that effect before applying.
|
||||
//! No automatic opt-in or overwrite of individual exceptions is provided.
|
||||
//!
|
||||
//! `builtin:async-chat` requests the conversation composition and an explicit Subject
|
||||
//! connection selects the body. Neither action writes policy. All entries below target
|
||||
//! exactly that Subject in its storage scope; none covers Workspace, files, Workers or
|
||||
//! other Subjects. Background Worker delegation is deliberately empty. Host-owned
|
||||
//! Memory/Journal Jobs additionally require their kind/attempt/input/writer grants;
|
||||
//! these source permissions alone do not create or authorize an arbitrary Job.
|
||||
use super::{PermissionResource, ScopedPermission};
|
||||
|
||||
pub const SUBJECT_CONVERSATION_PROFILE: &str = "builtin:async-chat";
|
||||
|
||||
/// Basic body operations and the current-policy ceiling for standard memory Jobs.
|
||||
/// Confirmed Memory mutation remains behind consolidation; explicit remembering stages
|
||||
/// evidence, never directly edits confirmed Memory. Journal recording is a separate Job.
|
||||
pub const SUBJECT_CONVERSATION_OPERATIONS: &[&str] = &[
|
||||
"session.record",
|
||||
"session.list",
|
||||
"session.search",
|
||||
"session.read",
|
||||
"chat.read",
|
||||
"chat.send",
|
||||
"memory.query",
|
||||
"memory.read",
|
||||
"memory.changes.list",
|
||||
"memory.resident.read",
|
||||
"memory.resident.supply",
|
||||
"memory.staging.write",
|
||||
"memory.staging.list",
|
||||
"memory.staging.read",
|
||||
"memory.receipt.read",
|
||||
"memory.proposal.validate",
|
||||
"memory.candidate.decide",
|
||||
"memory.extract.read",
|
||||
"memory.extract.supply",
|
||||
"memory.extract.commit",
|
||||
"memory.consolidation.request",
|
||||
"memory.consolidation.supply",
|
||||
"memory.surface.prepare",
|
||||
"memory.surface.supply",
|
||||
"memory.surface.publish",
|
||||
"memory.surface.fail",
|
||||
"journal.startup.read",
|
||||
"journal.startup.supply",
|
||||
"journal.compaction.read",
|
||||
"journal.compaction.supply",
|
||||
"journal.recording.request",
|
||||
"journal.job.input.read",
|
||||
"journal.job.input.supply",
|
||||
"journal.state.read",
|
||||
"journal.text.read",
|
||||
"journal.operation.query",
|
||||
"journal.operation.list",
|
||||
"journal.operation.coverage.read",
|
||||
"journal.entry.create",
|
||||
"journal.entry.fix",
|
||||
"journal.entry.rewrite",
|
||||
"journal.position.update",
|
||||
"journal.unchanged.confirm",
|
||||
"journal.checkpoint",
|
||||
"journal.finish",
|
||||
];
|
||||
|
||||
pub fn subject_conversation_permissions(scope: &str, subject: &str) -> Vec<ScopedPermission> {
|
||||
SUBJECT_CONVERSATION_OPERATIONS
|
||||
.iter()
|
||||
.map(|operation| ScopedPermission {
|
||||
operation: (*operation).into(),
|
||||
resource: PermissionResource::Subject {
|
||||
workspace_id: scope.into(),
|
||||
subject_id: subject.into(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -5640,6 +5640,8 @@ pub struct SubjektivSubjectDeleteResponse {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectCreateRequest {
|
||||
/// Stable across exact retries; changed input with the same ID conflicts.
|
||||
pub operation_id: String,
|
||||
pub role: String,
|
||||
#[serde(default)]
|
||||
pub behavior_md: String,
|
||||
@@ -11253,7 +11255,7 @@ pub fn memory_api_typescript() -> String {
|
||||
// `ts-rs` solely for this read-only projection.
|
||||
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
|
||||
|
||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
||||
export type SubjektivSubjectCreateRequest = { operation_id: string, role: string, behavior_md?: string, };
|
||||
|
||||
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
|
||||
|
||||
|
||||
@@ -640,12 +640,21 @@ fn policy(
|
||||
operation: &str,
|
||||
expected: Option<&str>,
|
||||
) -> String {
|
||||
// Initial test replacement observes the source installed by normal Subject creation.
|
||||
// Keep explicit expectations unchanged so replacement races still use their pinned CAS.
|
||||
let expected = expected.map(str::to_owned).unwrap_or_else(|| {
|
||||
store
|
||||
.subject_permissions(subject)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id
|
||||
});
|
||||
store
|
||||
.set_subject_permissions(
|
||||
subject,
|
||||
&server_api::authorization::SubjectPermissionsUpdate {
|
||||
operation_id: operation.into(),
|
||||
expected_change_id: expected.map(str::to_owned),
|
||||
expected_change_id: Some(expected),
|
||||
execution: Vec::new(),
|
||||
delegation: Vec::new(),
|
||||
},
|
||||
|
||||
@@ -226,7 +226,12 @@ fn deletion_identity_conflicts_are_scope_wide_but_do_not_cross_workspaces() {
|
||||
assert_eq!(store.list_memories(&b).unwrap().len(), 1);
|
||||
let fresh = store.create_subject(role()).unwrap().id;
|
||||
assert!(store.list_memories(&fresh).unwrap().is_empty());
|
||||
assert!(store.subject_permissions(&fresh).unwrap().is_none());
|
||||
let fresh_source = store.subject_permissions(&fresh).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
fresh_source.execution,
|
||||
server_api::authorization::subject_conversation_permissions(store.scope_id(), &fresh)
|
||||
);
|
||||
assert!(fresh_source.delegation.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -259,8 +264,14 @@ fn concurrent_begin_and_write_serialize_and_exact_finish_is_replayable() {
|
||||
fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let a = store.create_subject(role()).unwrap().id;
|
||||
let b = store.create_subject(role()).unwrap().id;
|
||||
let a = store
|
||||
.create_subject_idempotent(role(), "private behavior a".into(), "create-a")
|
||||
.unwrap()
|
||||
.id;
|
||||
let b = store
|
||||
.create_subject_idempotent(role(), "private behavior b".into(), "create-b")
|
||||
.unwrap()
|
||||
.id;
|
||||
store
|
||||
.stage_candidate(candidate(&a, "a", store.workspace_id()))
|
||||
.unwrap();
|
||||
@@ -268,11 +279,18 @@ fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped(
|
||||
.stage_candidate(candidate(&b, "b", store.workspace_id()))
|
||||
.unwrap();
|
||||
store.begin_subject_deletion(&a, "delete").unwrap();
|
||||
store.database.try_with_connection::<_, SubjektivError>(|c| {
|
||||
let creation_receipts = store.database.try_with_connection::<_, SubjektivError>(|c| {
|
||||
let tables = c.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")?.query_map([], |r| r.get::<_,String>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
||||
for table in tables {
|
||||
let columns = c.prepare(&format!("PRAGMA table_info({table})"))?.query_map([], |r| r.get::<_,String>(1))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
||||
if !columns.iter().any(|v| v == "subject_id") || table.starts_with("subject_delet") { continue; }
|
||||
if table == "subject_creation_receipts" {
|
||||
// Retained non-content identity tombstones prevent duplicate recreation after erase.
|
||||
// They have no Subject FK or content to purge, and intentionally need no deletion fence.
|
||||
assert_eq!(columns, ["operation_id", "subject_id", "request_fingerprint"]);
|
||||
assert!(c.prepare("PRAGMA foreign_key_list(subject_creation_receipts)")?.query([])?.next()?.is_none());
|
||||
continue;
|
||||
}
|
||||
assert!(DIRECT.contains(&table.as_str()) || table == "journal_erased_sessions" || table == "chat_conversations", "unclassified Subject table: {table}");
|
||||
}
|
||||
for table in DIRECT.iter().copied().chain(INDIRECT.iter().map(|v| v.0)).chain(["journal_erased_sessions"]) {
|
||||
@@ -289,9 +307,22 @@ fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped(
|
||||
assert!(c.execute("DELETE FROM staging_records WHERE subject_id=?1", [&b]).is_err());
|
||||
assert!(c.execute("DELETE FROM subject_deletions WHERE subject_id=?1", [&a]).is_err());
|
||||
assert!(c.execute("UPDATE subject_deletions SET operation_id='changed' WHERE subject_id=?1", [&a]).is_err());
|
||||
Ok(())
|
||||
Ok(c.prepare("SELECT operation_id,subject_id,request_fingerprint FROM subject_creation_receipts ORDER BY operation_id")?
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}).unwrap();
|
||||
assert_eq!(creation_receipts.len(), 2);
|
||||
assert_eq!(creation_receipts[0].1, a);
|
||||
assert_eq!(creation_receipts[1].1, b);
|
||||
store.finish_subject_deletion(&a, "delete").unwrap();
|
||||
let retained = store.database.try_with_connection::<_, SubjektivError>(|c| {
|
||||
Ok(c.prepare("SELECT operation_id,subject_id,request_fingerprint FROM subject_creation_receipts ORDER BY operation_id")?
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}).unwrap();
|
||||
assert_eq!(retained, creation_receipts);
|
||||
assert!(store.subject(&a).unwrap().is_none());
|
||||
assert!(store.subject(&b).unwrap().is_some());
|
||||
assert!(store.staging_candidate(&b, "b").unwrap().is_some());
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,13 @@ impl Fixture {
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||
let subject = store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let subject = if migrations.last().unwrap().version() < 12 {
|
||||
crate::journal::tests::pre_permissions_subject_fixture(&store, "companion", "")
|
||||
} else {
|
||||
store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap()
|
||||
};
|
||||
let attr =
|
||||
SubjectSessionAttribution::new(&subject.id, "runtime", "worker", "session").unwrap();
|
||||
store.record_session_attribution(attr.clone()).unwrap();
|
||||
|
||||
@@ -429,12 +429,16 @@ mod input_delivery {
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||
let subject = store
|
||||
.create_subject_with_behavior(
|
||||
SubjectRole::new("preserved").unwrap(),
|
||||
"Existing behavior".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let subject = if baseline.last().unwrap().version() < 12 {
|
||||
pre_permissions_subject_fixture(&store, "preserved", "Existing behavior")
|
||||
} else {
|
||||
store
|
||||
.create_subject_with_behavior(
|
||||
SubjectRole::new("preserved").unwrap(),
|
||||
"Existing behavior".into(),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
drop(store);
|
||||
drop(scope);
|
||||
manager.shutdown().unwrap();
|
||||
@@ -1591,6 +1595,42 @@ use sha2::{Digest, Sha256};
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
|
||||
pub(super) fn pre_permissions_subject_fixture(
|
||||
store: &SubjektivStore,
|
||||
role: &str,
|
||||
behavior: &str,
|
||||
) -> crate::SubjectRecord {
|
||||
// Freeze the schema 7–11 row/record shape; today's create API also writes policy tables.
|
||||
assert!((7..=11).contains(&store.database.schema_version().unwrap()));
|
||||
let record = serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"id": "legacy-subject",
|
||||
"role": role,
|
||||
"behavior_md": behavior,
|
||||
"state": "active",
|
||||
"created_at": "2026-09-28T10:00:00.000Z",
|
||||
"updated_at": "2026-09-28T10:00:00.000Z"
|
||||
});
|
||||
store
|
||||
.database
|
||||
.try_transaction::<_, crate::SubjektivError>(|tx| {
|
||||
tx.execute(
|
||||
"INSERT INTO subjects(subject_id,role,behavior_md,state,record_json,created_at,updated_at)
|
||||
VALUES(?1,?2,?3,'active',?4,?5,?5)",
|
||||
rusqlite::params![
|
||||
"legacy-subject",
|
||||
role,
|
||||
behavior,
|
||||
record.to_string(),
|
||||
"2026-09-28T10:00:00.000Z",
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
serde_json::from_value(record).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_erasure_releases_only_after_all_body_sessions_including_unadmitted_history_are_erased()
|
||||
{
|
||||
@@ -3133,16 +3173,17 @@ impl Fixture {
|
||||
SubjektivStore::register(&workspace).unwrap()
|
||||
};
|
||||
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||
let subject = store
|
||||
.create_subject_with_behavior(
|
||||
SubjectRole::new("companion").unwrap(),
|
||||
if upgrade {
|
||||
"Preserve existing Subject behavior".into()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
let subject = if upgrade {
|
||||
pre_permissions_subject_fixture(
|
||||
&store,
|
||||
"companion",
|
||||
"Preserve existing Subject behavior",
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap()
|
||||
};
|
||||
let attribution =
|
||||
SubjectSessionAttribution::new(&subject.id, "runtime", "worker", "source-session")
|
||||
.unwrap();
|
||||
@@ -5508,12 +5549,7 @@ fn journal_migration_failure_rolls_back_ddl_and_retries_without_changing_existin
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||
let subject = store
|
||||
.create_subject_with_behavior(
|
||||
SubjectRole::new("preserved").unwrap(),
|
||||
"Original behavior".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let subject = pre_permissions_subject_fixture(&store, "preserved", "Original behavior");
|
||||
store
|
||||
.database
|
||||
.try_transaction::<_, JournalError>(|tx| {
|
||||
|
||||
+23
-28
@@ -9,6 +9,7 @@ pub mod journal;
|
||||
mod legacy_migrations;
|
||||
pub mod permissions;
|
||||
mod schema;
|
||||
mod subject_creation;
|
||||
use legacy_migrations::{
|
||||
add_candidate_decision_receipts, add_subject_behavior, add_subject_session_attribution,
|
||||
add_surface_generation_state, add_surface_job_provenance, create_schema,
|
||||
@@ -54,6 +55,8 @@ pub enum SubjektivError {
|
||||
InvalidRecord(String),
|
||||
#[error("subject `{0}` was not found in this Workspace")]
|
||||
SubjectNotFound(String),
|
||||
#[error("Subject creation request `{0}` was reused with different input")]
|
||||
SubjectCreationConflict(String),
|
||||
#[error("Subject deletion request `{0}` conflicts with a saved deletion")]
|
||||
SubjectDeletionConflict(String),
|
||||
#[error("subject `{0}` is legacy-retired")]
|
||||
@@ -658,6 +661,11 @@ static MIGRATIONS: &[FeatureMigration] = &[
|
||||
deletion::migrate,
|
||||
),
|
||||
FeatureMigration::new(15, "add retained text-only conversations", chat::migrate),
|
||||
FeatureMigration::new(
|
||||
16,
|
||||
"retain replayable Subject creation receipts",
|
||||
subject_creation::migrate,
|
||||
),
|
||||
];
|
||||
|
||||
pub const REGISTRATION: FeatureRegistration =
|
||||
@@ -754,34 +762,21 @@ impl SubjektivStore {
|
||||
role: SubjectRole,
|
||||
behavior_md: String,
|
||||
) -> Result<SubjectRecord> {
|
||||
validate_subject_behavior(&behavior_md)?;
|
||||
let timestamp = now();
|
||||
let record = SubjectRecord {
|
||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||
id: issued_id("subject"),
|
||||
role,
|
||||
behavior_md,
|
||||
state: SubjectState::Active,
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
};
|
||||
let raw = serde_json::to_string(&record)?;
|
||||
self.database.try_transaction(|transaction| {
|
||||
transaction.execute(
|
||||
"INSERT INTO subjects (
|
||||
subject_id, role, behavior_md, state, record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, 'active', ?4, ?5, ?6)",
|
||||
params![
|
||||
record.id,
|
||||
record.role.as_str(),
|
||||
record.behavior_md,
|
||||
raw,
|
||||
record.created_at,
|
||||
record.updated_at
|
||||
],
|
||||
)?;
|
||||
Ok(record)
|
||||
})
|
||||
self.create_subject_request(role, behavior_md, None)
|
||||
}
|
||||
|
||||
/// Replays the original creation without reissuing permissions or changing the Subject.
|
||||
/// The Host authenticates the administrator before calling. Keep the same operation ID
|
||||
/// and full input after an unknown outcome; different input is a conflict.
|
||||
pub fn create_subject_idempotent(
|
||||
&self,
|
||||
role: SubjectRole,
|
||||
behavior_md: String,
|
||||
operation_id: &str,
|
||||
) -> Result<SubjectRecord> {
|
||||
server_api::authorization::validate_authorization_identifier(operation_id)
|
||||
.map_err(SubjektivError::InvalidRecord)?;
|
||||
self.create_subject_request(role, behavior_md, Some(operation_id))
|
||||
}
|
||||
|
||||
pub fn subject(&self, subject_id: &str) -> Result<Option<SubjectRecord>> {
|
||||
|
||||
@@ -68,16 +68,71 @@ fn open_version(
|
||||
(manager, scope, store)
|
||||
}
|
||||
|
||||
fn legacy_unset_source_fixture(store: &SubjektivStore, subject: &str) {
|
||||
// Model an unset legacy source without deleting immutable permission history or receipts.
|
||||
store
|
||||
.database
|
||||
.try_transaction::<_, SubjektivError>(|tx| {
|
||||
assert_eq!(
|
||||
tx.execute(
|
||||
"DELETE FROM subject_permissions WHERE subject_id=?1",
|
||||
[subject]
|
||||
)?,
|
||||
1
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.subject_permissions(subject).unwrap().is_none());
|
||||
}
|
||||
|
||||
fn pre_permissions_subject_fixture(store: &SubjektivStore, behavior: &str) -> SubjectRecord {
|
||||
// Freeze the version-11 row/record shape rather than invoking today's initializing create API.
|
||||
assert_eq!(store.database.schema_version().unwrap(), 11);
|
||||
let record = serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"id": "legacy-subject",
|
||||
"role": role().as_str(),
|
||||
"behavior_md": behavior,
|
||||
"state": "active",
|
||||
"created_at": "2026-09-28T10:00:00.000Z",
|
||||
"updated_at": "2026-09-28T10:00:00.000Z"
|
||||
});
|
||||
store
|
||||
.database
|
||||
.try_transaction::<_, SubjektivError>(|tx| {
|
||||
tx.execute(
|
||||
"INSERT INTO subjects(subject_id,role,behavior_md,state,record_json,created_at,updated_at)
|
||||
VALUES(?1,?2,?3,'active',?4,?5,?5)",
|
||||
params![
|
||||
"legacy-subject",
|
||||
role().as_str(),
|
||||
behavior,
|
||||
record.to_string(),
|
||||
"2026-09-28T10:00:00.000Z",
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
serde_json::from_value(record).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacements_are_cas_fenced_replayable_and_never_restore_old_permissions() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let subject;
|
||||
let first;
|
||||
let first_update = update("allow", None, vec![permission("file.read", ".", true)]);
|
||||
let first_update;
|
||||
{
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
subject = store.create_subject(role()).unwrap().id;
|
||||
assert!(store.subject_permissions(&subject).unwrap().is_none());
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
first_update = update(
|
||||
"allow",
|
||||
Some(&initial.change_id),
|
||||
vec![permission("file.read", ".", true)],
|
||||
);
|
||||
first = store
|
||||
.set_subject_permissions(&subject, &first_update)
|
||||
.unwrap();
|
||||
@@ -144,8 +199,12 @@ fn failed_receipt_insert_rolls_back_source_history_and_projection() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
let first = store
|
||||
.set_subject_permissions(&subject, &update("first", None, Vec::new()))
|
||||
.set_subject_permissions(
|
||||
&subject,
|
||||
&update("first", Some(&initial.change_id), Vec::new()),
|
||||
)
|
||||
.unwrap();
|
||||
store.database.try_transaction::<_, SubjektivError>(|tx| {
|
||||
tx.execute_batch("CREATE TRIGGER fail_permission_receipt BEFORE INSERT ON subject_permission_receipts BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?;
|
||||
@@ -164,7 +223,7 @@ fn failed_receipt_insert_rolls_back_source_history_and_projection() {
|
||||
assert_eq!(store.subject_permissions(&subject).unwrap(), Some(first));
|
||||
store.database.try_transaction::<_, SubjektivError>(|tx| {
|
||||
let counts: (u32, u32) = tx.query_row("SELECT (SELECT count(*) FROM subject_permission_changes),(SELECT count(*) FROM subject_permission_receipts)", [], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
assert_eq!(counts, (1, 1));
|
||||
assert_eq!(counts, (2, 1));
|
||||
tx.execute_batch("DROP TRIGGER fail_permission_receipt")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
@@ -181,6 +240,7 @@ fn scope_and_validation_rejections_do_not_issue_permission_sources() {
|
||||
let (_a_manager, _a_scope, a) = open_store(&temp.path().join("a"), "workspace-a");
|
||||
let (_b_manager, _b_scope, b) = open_store(&temp.path().join("b"), "workspace-b");
|
||||
let subject = a.create_subject(role()).unwrap().id;
|
||||
legacy_unset_source_fixture(&a, &subject);
|
||||
assert!(
|
||||
b.set_subject_permissions(&subject, &update("cross-subject", None, Vec::new()))
|
||||
.is_err()
|
||||
@@ -220,10 +280,15 @@ fn child_grants_are_immutable_bounded_and_do_not_create_subject_ownership() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
let source = store
|
||||
.set_subject_permissions(
|
||||
&subject,
|
||||
&update("allow", None, vec![permission("file.read", "src", true)]),
|
||||
&update(
|
||||
"allow",
|
||||
Some(&initial.change_id),
|
||||
vec![permission("file.read", "src", true)],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let child = grant(&subject, &source.change_id, "child");
|
||||
@@ -278,10 +343,15 @@ fn redelegation_requires_current_source_and_explicit_retained_parent_ceiling() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
let source = store
|
||||
.set_subject_permissions(
|
||||
&subject,
|
||||
&update("allow", None, vec![permission("file.read", ".", true)]),
|
||||
&update(
|
||||
"allow",
|
||||
Some(&initial.change_id),
|
||||
vec![permission("file.read", ".", true)],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut child = grant(&subject, &source.change_id, "child");
|
||||
@@ -325,10 +395,15 @@ fn journal_state_neither_authenticates_roots_nor_bypasses_retained_child_ceiling
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
let source = store
|
||||
.set_subject_permissions(
|
||||
&subject,
|
||||
&update("allow", None, vec![permission("file.read", ".", true)]),
|
||||
&update(
|
||||
"allow",
|
||||
Some(&initial.change_id),
|
||||
vec![permission("file.read", ".", true)],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// The Host has authenticated root ownership outside this store. No Journal record is
|
||||
@@ -369,10 +444,15 @@ fn retained_origins_reject_cycles_foreign_subjects_and_stale_parent_sources() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
let source = store
|
||||
.set_subject_permissions(
|
||||
&subject,
|
||||
&update("allow", None, vec![permission("file.read", ".", true)]),
|
||||
&update(
|
||||
"allow",
|
||||
Some(&initial.change_id),
|
||||
vec![permission("file.read", ".", true)],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut child = grant(&subject, &source.change_id, "child");
|
||||
@@ -386,8 +466,16 @@ fn retained_origins_reject_cycles_foreign_subjects_and_stale_parent_sources() {
|
||||
Err(SubjektivError::AuthorizationDenied(_))
|
||||
));
|
||||
let other = store.create_subject(role()).unwrap().id;
|
||||
let other_initial = store.subject_permissions(&other).unwrap().unwrap();
|
||||
let other_source = store
|
||||
.set_subject_permissions(&other, &update("other", None, source.delegation.clone()))
|
||||
.set_subject_permissions(
|
||||
&other,
|
||||
&update(
|
||||
"other",
|
||||
Some(&other_initial.change_id),
|
||||
source.delegation.clone(),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut foreign = grant(&other, &other_source.change_id, "foreign-child");
|
||||
foreign.origin_worker_id = "child".into();
|
||||
@@ -414,10 +502,15 @@ fn failed_grant_insert_leaves_no_binding_and_can_retry_same_grant() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
let source = store
|
||||
.set_subject_permissions(
|
||||
&subject,
|
||||
&update("allow", None, vec![permission("file.read", ".", true)]),
|
||||
&update(
|
||||
"allow",
|
||||
Some(&initial.change_id),
|
||||
vec![permission("file.read", ".", true)],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let child = grant(&subject, &source.change_id, "child");
|
||||
@@ -470,9 +563,7 @@ fn current_schema_migration_preserves_subject_and_matches_new_database() {
|
||||
let original;
|
||||
{
|
||||
let (_old_manager, _old_scope, old) = open_version(&root, 11);
|
||||
original = old
|
||||
.create_subject_with_behavior(role(), "Keep behavior separate".into())
|
||||
.unwrap();
|
||||
original = pre_permissions_subject_fixture(&old, "Keep behavior separate");
|
||||
}
|
||||
let (_migrated_manager, _migrated_scope, migrated) = open_store(&root, "workspace-a");
|
||||
assert_eq!(
|
||||
@@ -502,6 +593,7 @@ fn unset_or_execution_only_source_cannot_issue_child_grants_and_saved_grants_sur
|
||||
{
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
legacy_unset_source_fixture(&store, &subject);
|
||||
assert!(
|
||||
store
|
||||
.bind_worker_execution_grant(&grant(&subject, "unset", "child"))
|
||||
@@ -556,14 +648,27 @@ fn subject_grant_listing_is_scoped_retained_ordered_and_fails_on_overflow() {
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
let source = store
|
||||
.set_subject_permissions(
|
||||
&subject,
|
||||
&update("allow", None, vec![permission("file.read", ".", true)]),
|
||||
&update(
|
||||
"allow",
|
||||
Some(&initial.change_id),
|
||||
vec![permission("file.read", ".", true)],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let other_initial = store.subject_permissions(&other).unwrap().unwrap();
|
||||
let other_source = store
|
||||
.set_subject_permissions(&other, &update("other", None, source.delegation.clone()))
|
||||
.set_subject_permissions(
|
||||
&other,
|
||||
&update(
|
||||
"other",
|
||||
Some(&other_initial.change_id),
|
||||
source.delegation.clone(),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let other_grant = grant(&other, &other_source.change_id, "other-child");
|
||||
store.bind_worker_execution_grant(&other_grant).unwrap();
|
||||
@@ -631,7 +736,7 @@ fn failed_schema_migration_rolls_back_new_tables_and_preserves_old_data() {
|
||||
let original;
|
||||
{
|
||||
let (_old_manager, _old_scope, old) = open_version(temp.path(), 11);
|
||||
original = old.create_subject(role()).unwrap();
|
||||
original = pre_permissions_subject_fixture(&old, "");
|
||||
old.database
|
||||
.try_transaction::<_, SubjektivError>(|tx| {
|
||||
tx.execute_batch("CREATE TABLE subject_permissions (collision TEXT)")?;
|
||||
@@ -660,7 +765,12 @@ fn deletion_fences_source_and_child_grant_replays_and_never_falls_back_after_pur
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap().id;
|
||||
let replacement = update("allow", None, vec![permission("file.read", ".", true)]);
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
let replacement = update(
|
||||
"allow",
|
||||
Some(&initial.change_id),
|
||||
vec![permission("file.read", ".", true)],
|
||||
);
|
||||
let source = store
|
||||
.set_subject_permissions(&subject, &replacement)
|
||||
.unwrap();
|
||||
@@ -688,7 +798,12 @@ fn deletion_fences_source_and_child_grant_replays_and_never_falls_back_after_pur
|
||||
.is_none()
|
||||
);
|
||||
let fresh = store.create_subject(role()).unwrap().id;
|
||||
assert!(store.subject_permissions(&fresh).unwrap().is_none());
|
||||
let fresh_source = store.subject_permissions(&fresh).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
fresh_source.execution,
|
||||
server_api::authorization::subject_conversation_permissions("workspace-a", &fresh)
|
||||
);
|
||||
assert!(fresh_source.delegation.is_empty());
|
||||
assert!(
|
||||
store
|
||||
.record_session_attribution(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! New Subjects receive their self-scoped conversation source in the creation transaction.
|
||||
//! This path is never called by migration, launch, restore or permission reads. Receipts
|
||||
//! retain only the issued identity and input fingerprint across erasure, preventing replay
|
||||
//! from recreating an erased Subject or restoring a revoked policy.
|
||||
use super::*;
|
||||
use server_api::authorization::{SubjectPermissions, subject_conversation_permissions};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||
tx.execute_batch(
|
||||
"CREATE TABLE subject_creation_receipts (
|
||||
operation_id TEXT PRIMARY KEY CHECK(length(operation_id)>0),
|
||||
subject_id TEXT NOT NULL UNIQUE,
|
||||
request_fingerprint TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl SubjektivStore {
|
||||
pub(super) fn create_subject_request(
|
||||
&self,
|
||||
role: SubjectRole,
|
||||
behavior_md: String,
|
||||
operation_id: Option<&str>,
|
||||
) -> Result<SubjectRecord> {
|
||||
validate_subject_behavior(&behavior_md)?;
|
||||
let fingerprint: String = Sha256::digest(serde_json::to_vec(&(&role, &behavior_md))?)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
self.database.try_transaction(|tx| {
|
||||
if let Some(operation) = operation_id {
|
||||
let receipt: Option<(String, String)> = tx.query_row(
|
||||
"SELECT subject_id,request_fingerprint FROM subject_creation_receipts WHERE operation_id=?1",
|
||||
[operation], |row| Ok((row.get(0)?, row.get(1)?)),
|
||||
).optional()?;
|
||||
if let Some((subject, saved)) = receipt {
|
||||
if saved != fingerprint {
|
||||
return Err(SubjektivError::SubjectCreationConflict(operation.into()));
|
||||
}
|
||||
// Current identity/state, never a stale success that can recreate deleted data.
|
||||
return require_active_subject(tx, &subject);
|
||||
}
|
||||
}
|
||||
let timestamp = now();
|
||||
let record = SubjectRecord {
|
||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||
id: issued_id("subject"), role, behavior_md,
|
||||
state: SubjectState::Active,
|
||||
created_at: timestamp.clone(), updated_at: timestamp,
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO subjects(subject_id,role,behavior_md,state,record_json,created_at,updated_at)
|
||||
VALUES(?1,?2,?3,'active',?4,?5,?6)",
|
||||
params![record.id,record.role.as_str(),record.behavior_md,serde_json::to_string(&record)?,record.created_at,record.updated_at],
|
||||
)?;
|
||||
let source = SubjectPermissions {
|
||||
change_id: issued_id("permissions"),
|
||||
execution: subject_conversation_permissions(self.scope_id(), &record.id),
|
||||
delegation: vec![],
|
||||
};
|
||||
tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)",
|
||||
params![record.id,source.change_id,serde_json::to_string(&source)?])?;
|
||||
tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?;
|
||||
if let Some(operation) = operation_id {
|
||||
tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint) VALUES(?1,?2,?3)",params![operation,record.id,fingerprint])?;
|
||||
}
|
||||
Ok(record)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,221 @@
|
||||
//! SQLite component coverage: normal store creation, persisted source/CAS and exact retries.
|
||||
//! Authentication/Runtime connections are covered by the Backend connected chat suite.
|
||||
use super::*;
|
||||
use crate::tests::{open_store, role};
|
||||
use server_api::authorization::{SubjectPermissionsUpdate, validate_managed_subject_permissions};
|
||||
|
||||
#[test]
|
||||
fn creation_persists_self_scoped_execution_and_no_delegation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(dir.path(), "workspace-a");
|
||||
let subject = store.create_subject(role()).unwrap();
|
||||
let source = store.subject_permissions(&subject.id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
source.execution,
|
||||
subject_conversation_permissions("workspace-a", &subject.id)
|
||||
);
|
||||
assert!(source.delegation.is_empty());
|
||||
validate_managed_subject_permissions(
|
||||
None,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "validate-initial".into(),
|
||||
expected_change_id: None,
|
||||
execution: source.execution.clone(),
|
||||
delegation: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for permission in source.execution {
|
||||
let mut foreign = permission.clone();
|
||||
foreign.resource = server_api::authorization::PermissionResource::Subject {
|
||||
workspace_id: "workspace-a".into(),
|
||||
subject_id: "other".into(),
|
||||
};
|
||||
assert!(!permission.covers(&foreign));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_replay_after_reopen_never_duplicates_or_restores_revoked_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let id;
|
||||
let revoked;
|
||||
{
|
||||
let (_manager, _scope, store) = open_store(dir.path(), "workspace-a");
|
||||
let first = store
|
||||
.create_subject_idempotent(role(), "original".into(), "create")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.create_subject_idempotent(role(), "original".into(), "create")
|
||||
.unwrap(),
|
||||
first
|
||||
);
|
||||
assert!(matches!(
|
||||
store.create_subject_idempotent(role(), "changed".into(), "create"),
|
||||
Err(SubjektivError::SubjectCreationConflict(_))
|
||||
));
|
||||
id = first.id;
|
||||
let source = store.subject_permissions(&id).unwrap().unwrap();
|
||||
revoked = store
|
||||
.set_subject_permissions(
|
||||
&id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "revoke".into(),
|
||||
expected_change_id: Some(source.change_id),
|
||||
execution: vec![],
|
||||
delegation: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let (_manager, _scope, store) = open_store(dir.path(), "workspace-a");
|
||||
assert_eq!(
|
||||
store
|
||||
.create_subject_idempotent(role(), "original".into(), "create")
|
||||
.unwrap()
|
||||
.id,
|
||||
id
|
||||
);
|
||||
assert_eq!(store.subject_permissions(&id).unwrap(), Some(revoked));
|
||||
assert_eq!(store.list_subjects_after(100, None).unwrap().items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_write_failure_rolls_back_subject_and_creation_receipt() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(dir.path(), "workspace-a");
|
||||
store.database.with_connection(|c| {
|
||||
Ok(c.execute_batch("CREATE TRIGGER fail_initial_policy BEFORE INSERT ON subject_permissions BEGIN SELECT RAISE(ABORT,'injected policy failure'); END;")?)
|
||||
}).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.create_subject_idempotent(role(), String::new(), "atomic")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.list_subjects_after(100, None)
|
||||
.unwrap()
|
||||
.items
|
||||
.is_empty()
|
||||
);
|
||||
store
|
||||
.database
|
||||
.with_connection(|c| Ok(c.execute_batch("DROP TRIGGER fail_initial_policy;")?))
|
||||
.unwrap();
|
||||
let subject = store
|
||||
.create_subject_idempotent(role(), String::new(), "atomic")
|
||||
.unwrap();
|
||||
assert!(store.subject_permissions(&subject.id).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_replay_after_erasure_does_not_recreate_subject_or_policy() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (_manager, _scope, store) = open_store(dir.path(), "workspace-a");
|
||||
let created = store
|
||||
.create_subject_idempotent(role(), "private".into(), "create-erased")
|
||||
.unwrap();
|
||||
store.begin_subject_deletion(&created.id, "erase").unwrap();
|
||||
assert!(
|
||||
store
|
||||
.create_subject_idempotent(role(), "private".into(), "create-erased")
|
||||
.is_err()
|
||||
);
|
||||
store.finish_subject_deletion(&created.id, "erase").unwrap();
|
||||
assert!(
|
||||
store
|
||||
.create_subject_idempotent(role(), "private".into(), "create-erased")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.list_subjects_after(100, None)
|
||||
.unwrap()
|
||||
.items
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_migration_preserves_existing_unset_and_revoked_policies_and_rolls_back_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manager = feature_storage::FeatureStorage::new(dir.path());
|
||||
let scope = manager.scope("workspace-a").unwrap();
|
||||
let old_registration = scope
|
||||
.register(feature_storage::FeatureRegistration::new(
|
||||
SUBJEKTIV_FEATURE_ID,
|
||||
&MIGRATIONS[..15],
|
||||
))
|
||||
.unwrap();
|
||||
let old = SubjektivStore::open(&scope, &old_registration).unwrap();
|
||||
let unset = old.create_subject(role()).unwrap();
|
||||
let revoked = old.create_subject(role()).unwrap();
|
||||
let source = old.subject_permissions(&revoked.id).unwrap().unwrap();
|
||||
let empty = old
|
||||
.set_subject_permissions(
|
||||
&revoked.id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "revoke-before-upgrade".into(),
|
||||
expected_change_id: Some(source.change_id),
|
||||
execution: vec![],
|
||||
delegation: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
old.database
|
||||
.with_connection(|c| {
|
||||
c.execute(
|
||||
"DELETE FROM subject_permissions WHERE subject_id=?1",
|
||||
[&unset.id],
|
||||
)?;
|
||||
c.execute_batch("CREATE TABLE subject_creation_receipts(conflicting_column TEXT);")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(old);
|
||||
drop(scope);
|
||||
drop(manager);
|
||||
{
|
||||
let manager = feature_storage::FeatureStorage::new(dir.path());
|
||||
let scope = manager.scope("workspace-a").unwrap();
|
||||
let registration = SubjektivStore::register(&scope).unwrap();
|
||||
assert!(SubjektivStore::open(&scope, ®istration).is_err());
|
||||
}
|
||||
let manager = feature_storage::FeatureStorage::new(dir.path());
|
||||
let scope = manager.scope("workspace-a").unwrap();
|
||||
let registration = scope
|
||||
.register(feature_storage::FeatureRegistration::new(
|
||||
SUBJEKTIV_FEATURE_ID,
|
||||
&MIGRATIONS[..15],
|
||||
))
|
||||
.unwrap();
|
||||
let old = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||
assert_eq!(old.database.schema_version().unwrap(), 15);
|
||||
assert_eq!(old.subject(&unset.id).unwrap(), Some(unset.clone()));
|
||||
assert_eq!(
|
||||
old.subject_permissions(&revoked.id).unwrap(),
|
||||
Some(empty.clone())
|
||||
);
|
||||
old.database
|
||||
.with_connection(|c| {
|
||||
c.execute_batch("DROP TABLE subject_creation_receipts;")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(old);
|
||||
drop(scope);
|
||||
drop(manager);
|
||||
let manager = feature_storage::FeatureStorage::new(dir.path());
|
||||
let scope = manager.scope("workspace-a").unwrap();
|
||||
let registration = SubjektivStore::register(&scope).unwrap();
|
||||
let upgraded = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||
assert_eq!(upgraded.subject_permissions(&unset.id).unwrap(), None);
|
||||
assert_eq!(
|
||||
upgraded.subject_permissions(&revoked.id).unwrap(),
|
||||
Some(empty)
|
||||
);
|
||||
let fresh = upgraded.create_subject(role()).unwrap();
|
||||
assert!(upgraded.subject_permissions(&fresh.id).unwrap().is_some());
|
||||
}
|
||||
@@ -495,6 +495,11 @@ pub fn sanitize_workspace_display_name(value: &str) -> Result<String> {
|
||||
|
||||
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
|
||||
let labels = [
|
||||
(
|
||||
server_api::authorization::SUBJECT_CONVERSATION_PROFILE,
|
||||
"Subject conversation",
|
||||
"Default Subject conversation and memory profile; requires an explicit Subject connection",
|
||||
),
|
||||
(
|
||||
"builtin:companion",
|
||||
"Companion",
|
||||
@@ -541,7 +546,8 @@ pub fn selector_for_builtin_candidate(
|
||||
id: &str,
|
||||
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
||||
match id {
|
||||
"builtin:companion"
|
||||
"builtin:async-chat"
|
||||
| "builtin:companion"
|
||||
| "builtin:intake"
|
||||
| "builtin:orchestrator"
|
||||
| "builtin:coder"
|
||||
|
||||
@@ -23856,7 +23856,7 @@ async fn scoped_create_subjektiv_subject(
|
||||
crate::subjektiv::validate_subject_behavior(&request.behavior_md)
|
||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||
let subject = open_subjektiv_store(&api)?
|
||||
.create_subject_with_behavior(role, request.behavior_md)
|
||||
.create_subject_idempotent(role, request.behavior_md, &request.operation_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
@@ -25752,6 +25752,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
|
||||
Error::WorkspacePermissionDenied(error.to_string())
|
||||
}
|
||||
crate::subjektiv::SubjektivError::SubjectPermissionsConflict(_)
|
||||
| crate::subjektiv::SubjektivError::SubjectCreationConflict(_)
|
||||
| crate::subjektiv::SubjektivError::SubjectDeletionConflict(_)
|
||||
| crate::subjektiv::SubjektivError::PermissionOperationConflict(_)
|
||||
| crate::subjektiv::SubjektivError::WorkerExecutionGrantConflict(_) => {
|
||||
@@ -45318,7 +45319,10 @@ mod tests {
|
||||
&subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "completion-grant".into(),
|
||||
expected_change_id: None,
|
||||
expected_change_id: store
|
||||
.subject_permissions(&subject_id)
|
||||
.unwrap()
|
||||
.map(|p| p.change_id),
|
||||
execution: vec![ScopedPermission {
|
||||
operation: "merge_request.complete".into(),
|
||||
resource: PermissionResource::MergeRequest {
|
||||
@@ -47388,7 +47392,7 @@ mod tests {
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||
Some(serde_json::json!({ "role": "Browser author" })),
|
||||
Some(serde_json::json!({ "operation_id":"browser-unauthorized", "role": "Browser author" })),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
@@ -47397,6 +47401,7 @@ mod tests {
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||
Some(serde_json::json!({
|
||||
"operation_id":"browser-create",
|
||||
"role": " Browser author ",
|
||||
"behavior_md": "Prefer explicit evidence.\nAsk when uncertain."
|
||||
})),
|
||||
@@ -47483,7 +47488,7 @@ mod tests {
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||
Some(serde_json::json!({ "role": " " })),
|
||||
Some(serde_json::json!({ "operation_id":"invalid-role", "role": " " })),
|
||||
&owner_token,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
@@ -47496,7 +47501,9 @@ mod tests {
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/w/another-workspace/subjektiv/subjects",
|
||||
Some(serde_json::json!({ "role": "Cross Workspace" })),
|
||||
Some(
|
||||
serde_json::json!({ "operation_id":"cross-workspace", "role": "Cross Workspace" }),
|
||||
),
|
||||
&owner_token,
|
||||
StatusCode::NOT_FOUND,
|
||||
)
|
||||
@@ -47581,6 +47588,7 @@ mod tests {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectCreateRequest {
|
||||
operation_id: format!("create-subject-{}", uuid::Uuid::now_v7()),
|
||||
role: "companion".to_string(),
|
||||
behavior_md: "Prefer explicit evidence.".to_string(),
|
||||
}),
|
||||
@@ -47644,6 +47652,7 @@ mod tests {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectCreateRequest {
|
||||
operation_id: format!("create-subject-{}", uuid::Uuid::now_v7()),
|
||||
role: "reviewer".to_string(),
|
||||
behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1),
|
||||
}),
|
||||
@@ -48139,6 +48148,18 @@ mod tests {
|
||||
serde_json::to_vec(&body).unwrap(),
|
||||
)
|
||||
};
|
||||
let initial = store.subject_permissions(&subject.id).unwrap().unwrap();
|
||||
store
|
||||
.set_subject_permissions(
|
||||
&subject.id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "bound-authorization-revoke".into(),
|
||||
expected_change_id: Some(initial.change_id),
|
||||
execution: vec![],
|
||||
delegation: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let denied = build_router(api.clone())
|
||||
.oneshot(check(subject_request.clone()))
|
||||
.await
|
||||
@@ -48158,7 +48179,10 @@ mod tests {
|
||||
&subject.id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: Uuid::now_v7().to_string(),
|
||||
expected_change_id: None,
|
||||
expected_change_id: store
|
||||
.subject_permissions(&subject.id)
|
||||
.unwrap()
|
||||
.map(|p| p.change_id),
|
||||
execution: vec![
|
||||
ScopedPermission {
|
||||
operation: "memory.resident.supply".into(),
|
||||
@@ -48287,7 +48311,11 @@ mod tests {
|
||||
&subject.id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "staging-scope-fixture".into(),
|
||||
expected_change_id: None,
|
||||
expected_change_id: open_subjektiv_store(&api)
|
||||
.unwrap()
|
||||
.subject_permissions(&subject.id)
|
||||
.unwrap()
|
||||
.map(|p| p.change_id),
|
||||
execution,
|
||||
delegation: vec![],
|
||||
},
|
||||
|
||||
@@ -775,7 +775,7 @@ async fn connected_chat_delegation(scenario: Scenario) {
|
||||
];
|
||||
foreign = Some((other_subject, other_ticket));
|
||||
}
|
||||
account_request(&h,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"chat-policy","expected_change_id":null,"execution":execution,"delegation":delegation}))).await;
|
||||
account_request(&h,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"chat-policy","expected_change_id":store.subject_permissions(&subject).unwrap().unwrap().change_id,"execution":execution,"delegation":delegation}))).await;
|
||||
let original_policy = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
for (name, target) in [("conversation-a", true), ("conversation-b", false)] {
|
||||
let c = account_request(
|
||||
|
||||
@@ -670,7 +670,10 @@ impl agen::llm_client::LlmClient for ChatModel {
|
||||
is_error, content, ..
|
||||
} = item
|
||||
{
|
||||
assert!(!is_error, "real connected chat Tool failed: {content:?}");
|
||||
assert!(
|
||||
!is_error,
|
||||
"real connected chat Tool failed: {item:?}; detail={content:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let events = match phase {
|
||||
@@ -712,11 +715,33 @@ impl agen::llm_client::LlmClient for ChatModel {
|
||||
request.tools.iter().any(|tool| tool.name == name),
|
||||
"builtin:async-chat must expose {name}"
|
||||
);
|
||||
vec![
|
||||
let mut events = vec![
|
||||
Event::tool_use_start(0, format!("chat-tool-{phase}"), name),
|
||||
Event::tool_input_delta(0, input.to_string()),
|
||||
Event::tool_use_stop(0),
|
||||
]
|
||||
];
|
||||
if phase == 3 {
|
||||
for (index, name, input) in [
|
||||
(1, "SubjektivMemoryQuery", json!({})),
|
||||
(
|
||||
2,
|
||||
"SubjektivMemoryRemember",
|
||||
json!({"kind":"lesson","claim":"The connected chat path supports explicit remembering.","why_useful":"Retain tested conversation behavior."}),
|
||||
),
|
||||
(3, "SubjektivSessionList", json!({})),
|
||||
] {
|
||||
assert!(
|
||||
request.tools.iter().any(|tool| tool.name == name),
|
||||
"default profile must expose {name}"
|
||||
);
|
||||
events.extend([
|
||||
Event::tool_use_start(index, format!("default-memory-{index}"), name),
|
||||
Event::tool_input_delta(index, input.to_string()),
|
||||
Event::tool_use_stop(index),
|
||||
]);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
4..=6 => vec![
|
||||
Event::text_block_start(0),
|
||||
@@ -743,38 +768,11 @@ struct ConnectedChat {
|
||||
owner: String,
|
||||
url: String,
|
||||
lose_next_input_ack: Arc<std::sync::atomic::AtomicBool>,
|
||||
identity: RuntimeIdentityMaterial,
|
||||
_backend: AuthorizationCallbackServer,
|
||||
_runtime_server: ConnectedRuntimeServer,
|
||||
_dir: tempfile::TempDir,
|
||||
}
|
||||
fn publish_chat_fixture_profile(api: &WorkspaceApi) {
|
||||
// A bundled Profile is not automatically a Backend-published candidate.
|
||||
// Publish the actual built-in without copying or widening its capabilities.
|
||||
let state = api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let main = state
|
||||
.snapshot
|
||||
.get(&config_source::VirtualPath::parse("main.dcdl").unwrap())
|
||||
.unwrap();
|
||||
let content = r#"{ profile = { default_profile = "project:connected-chat"; entries = [{ selector = "project:connected-chat"; label = "Connected chat"; description = "Real built-in async chat fixture"; profile = import "$builtin/profiles/async-chat.dcdl"; }]; }; }"#;
|
||||
commit_workspace_config_tree(
|
||||
api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&ConfigCommitRequest {
|
||||
base_digest: state.snapshot.digest.clone(),
|
||||
entrypoints: state.contract.entrypoints.clone(),
|
||||
changes: vec![config_source::ConfigTreeChange::Update {
|
||||
path: main.path.clone(),
|
||||
expected_digest: main.content_digest.clone(),
|
||||
content: content.into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
impl ConnectedChat {
|
||||
async fn new(
|
||||
@@ -943,7 +941,6 @@ impl ConnectedChat {
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
publish_chat_fixture_profile(&api);
|
||||
seed_test_delivery_binding(&api);
|
||||
let owner =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "connected-chat-owner").await;
|
||||
@@ -957,6 +954,7 @@ impl ConnectedChat {
|
||||
owner,
|
||||
url,
|
||||
lose_next_input_ack,
|
||||
identity,
|
||||
_backend: backend,
|
||||
_runtime_server: runtime_server,
|
||||
_dir: dir,
|
||||
@@ -980,6 +978,54 @@ impl ConnectedChat {
|
||||
}
|
||||
}
|
||||
|
||||
fn connected_source_request(
|
||||
f: &ConnectedChat,
|
||||
worker: &str,
|
||||
path: &str,
|
||||
body: Vec<u8>,
|
||||
) -> Request<Body> {
|
||||
let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&f.identity)
|
||||
.issue(
|
||||
&f.url,
|
||||
TEST_WORKSPACE_ID,
|
||||
Some(worker),
|
||||
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
|
||||
"POST",
|
||||
path,
|
||||
&body,
|
||||
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(),
|
||||
30,
|
||||
)
|
||||
.unwrap();
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||
proof,
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn worker_chat_connected(
|
||||
f: &ConnectedChat,
|
||||
worker: &str,
|
||||
operation: Value,
|
||||
) -> (StatusCode, Value) {
|
||||
response(
|
||||
&f.api,
|
||||
connected_source_request(
|
||||
f,
|
||||
worker,
|
||||
&chat_worker_path(),
|
||||
serde_json::to_vec(&json!({"operation":operation})).unwrap(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn next_chat_input(
|
||||
inputs: &mut tokio::sync::mpsc::Receiver<(usize, String)>,
|
||||
expected: usize,
|
||||
@@ -1049,22 +1095,44 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
||||
})
|
||||
.await;
|
||||
let store = open_subjektiv_store(&f.api).unwrap();
|
||||
let subject = store
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("connected-chat").unwrap())
|
||||
let create_wire =
|
||||
json!({"operation_id":"connected-subject-create","role":"connected-chat","behavior_md":""});
|
||||
let subject = f
|
||||
.post(
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||
create_wire.clone(),
|
||||
)
|
||||
.await["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.id;
|
||||
let operations = [
|
||||
"session.record",
|
||||
"memory.resident.read",
|
||||
"memory.resident.supply",
|
||||
"journal.startup.read",
|
||||
"journal.startup.supply",
|
||||
// Restore uses the ordinary fail-closed recording hook as well.
|
||||
"journal.recording.request",
|
||||
"chat.read",
|
||||
"chat.send",
|
||||
];
|
||||
f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),json!({"operation_id":"connected-chat-policy","expected_change_id":null,"execution":operations.into_iter().map(|op|subject_permission(&subject,op)).collect::<Vec<_>>(),"delegation":[]})).await;
|
||||
.to_owned();
|
||||
assert_eq!(
|
||||
f.post(
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||
create_wire.clone()
|
||||
)
|
||||
.await["id"],
|
||||
subject
|
||||
);
|
||||
let (status, managed) = account(
|
||||
&f.api,
|
||||
&f.owner,
|
||||
"GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{managed}");
|
||||
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
managed["permissions"],
|
||||
serde_json::to_value(&initial).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
initial.execution,
|
||||
server_api::authorization::subject_conversation_permissions(TEST_WORKSPACE_ID, &subject)
|
||||
);
|
||||
assert!(initial.delegation.is_empty());
|
||||
let conversation = f
|
||||
.post(
|
||||
&conversations_path(),
|
||||
@@ -1073,7 +1141,7 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
||||
.await;
|
||||
let c = conversation["id"].as_str().unwrap();
|
||||
*conversation_id.lock().unwrap() = c.into();
|
||||
let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Connected async chat","profile":"project:connected-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
||||
let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Connected async chat","profile":"builtin:async-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
||||
let worker = created["worker_id"].as_str().unwrap();
|
||||
let mut events = f.runtime.subscribe_worker_observation().unwrap();
|
||||
let first_wire = json!({"operation_id":"connected-first","text":"CHAT_FIRST_USER_CONTENT"});
|
||||
@@ -1170,6 +1238,19 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
||||
"three user messages and two explicit Sends, not internal output"
|
||||
);
|
||||
assert!(!public.to_string().contains("INTERNAL_ASSISTANT"));
|
||||
let staged = store.pending_staging_candidates(&subject, 20).unwrap();
|
||||
assert!(
|
||||
staged
|
||||
.iter()
|
||||
.any(|candidate| candidate.claim
|
||||
== "The connected chat path supports explicit remembering."),
|
||||
"Remember must reach real authorized SQLite staging, not just return a fixture receipt: {staged:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
store.subject_permissions(&subject).unwrap(),
|
||||
Some(initial.clone()),
|
||||
"composition and memory operations never reissue policy"
|
||||
);
|
||||
let idle_wire = json!({"operation_id":"connected-idle","text":"CHAT_IDLE_USER_CONTENT"});
|
||||
let idle = f.post(&messages_path(c), idle_wire.clone()).await;
|
||||
assert_eq!(idle["delivery"]["state"], "accepted");
|
||||
@@ -1272,6 +1353,88 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
||||
"explicit retry after restore preserves one public User message"
|
||||
);
|
||||
assert!(!public.to_string().contains("INTERNAL_ASSISTANT"));
|
||||
let other = f
|
||||
.post(
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||
json!({"operation_id":"foreign-default-subject","role":"foreign"}),
|
||||
)
|
||||
.await["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
for permission in [
|
||||
subject_permission(&other, "chat.read"),
|
||||
ScopedPermission {
|
||||
operation: "filesystem.read".into(),
|
||||
resource: PermissionResource::Workdir {
|
||||
working_directory_id: "outside".into(),
|
||||
path: ".".into(),
|
||||
recursive: true,
|
||||
},
|
||||
},
|
||||
] {
|
||||
let (status, body) = response(
|
||||
&f.api,
|
||||
connected_source_request(
|
||||
&f,
|
||||
worker,
|
||||
&authorization_path(),
|
||||
serde_json::to_vec(&permission).unwrap(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"initial self source must not cover foreign resources: {body}"
|
||||
);
|
||||
}
|
||||
let revoked = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"), json!({"operation_id":"revoke-chat-defaults","expected_change_id":initial.change_id,"execution":[],"delegation":[]})).await;
|
||||
assert_eq!(
|
||||
f.post(
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||
create_wire
|
||||
)
|
||||
.await["id"],
|
||||
subject
|
||||
);
|
||||
let (status, body) = worker_chat_connected(&f, worker, read(c)).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"revocation cannot be repaired by profile or create replay: {body}"
|
||||
);
|
||||
f.post(&format!("{lifecycle_path}/stop"), json!({})).await;
|
||||
let token = f
|
||||
.api
|
||||
.runtime
|
||||
.worker(&worker_ref)
|
||||
.unwrap()
|
||||
.restore_observation_token
|
||||
.unwrap();
|
||||
let restored = f
|
||||
.post(
|
||||
&format!("{lifecycle_path}/restore"),
|
||||
json!({"request_id":"restore-revoked-defaults","expected_observation_token":token}),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(
|
||||
restored["result"]["state"], "accepted",
|
||||
"required startup authorization must refuse revoked source: {restored}"
|
||||
);
|
||||
let (status, managed) = account(
|
||||
&f.api,
|
||||
&f.owner,
|
||||
"GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
managed["permissions"], revoked["permissions"],
|
||||
"restore never reissues the initial policy"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -1302,21 +1465,15 @@ impl agen::llm_client::LlmClient for NotifyObservationModel {
|
||||
async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() {
|
||||
let (tx, mut inputs) = tokio::sync::mpsc::channel(32);
|
||||
let f = ConnectedChat::new(NotifyObservationModel(tx), |router| router).await;
|
||||
let subject = open_subjektiv_store(&f.api)
|
||||
let subject = f
|
||||
.post(
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||
json!({"operation_id":"notify-console-subject","role":"notify-console"}),
|
||||
)
|
||||
.await["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("notify-console").unwrap())
|
||||
.unwrap()
|
||||
.id;
|
||||
let operations = [
|
||||
"session.record",
|
||||
"memory.resident.read",
|
||||
"memory.resident.supply",
|
||||
"journal.startup.read",
|
||||
"journal.startup.supply",
|
||||
"chat.read",
|
||||
"chat.send",
|
||||
];
|
||||
f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),json!({"operation_id":"notify-console-policy","expected_change_id":null,"execution":operations.into_iter().map(|op|subject_permission(&subject,op)).collect::<Vec<_>>(),"delegation":[]})).await;
|
||||
.to_owned();
|
||||
let conversation = f
|
||||
.post(
|
||||
&conversations_path(),
|
||||
@@ -1324,7 +1481,7 @@ async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() {
|
||||
)
|
||||
.await;
|
||||
let c = conversation["id"].as_str().unwrap();
|
||||
let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Notify Console","profile":"project:connected-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
||||
let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Notify Console","profile":"builtin:async-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
||||
let worker = created["worker_id"].as_str().unwrap();
|
||||
let chat = f
|
||||
.post(
|
||||
|
||||
@@ -143,7 +143,14 @@ impl Fixture {
|
||||
&subject,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "fixture-session-setup".into(),
|
||||
expected_change_id: None,
|
||||
expected_change_id: Some(
|
||||
open_subjektiv_store(&api)
|
||||
.unwrap()
|
||||
.subject_permissions(&subject)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id,
|
||||
),
|
||||
execution: vec![subject_permission(&subject, "session.record")],
|
||||
delegation: vec![],
|
||||
},
|
||||
@@ -3085,7 +3092,7 @@ async fn connected_read_only_subject_delegates_editing_profile_to_real_runtime_r
|
||||
// not an unset body or a registry/singleton row manufactured by the test.
|
||||
let (status, body) = response(&api, human_request(&owner, "POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||
Some(json!({"operation_id":"connected-authorization-policy", "expected_change_id":null,
|
||||
Some(json!({"operation_id":"connected-authorization-policy", "expected_change_id":subject_store.subject_permissions(&subject).unwrap().unwrap().change_id,
|
||||
"execution":(["session.record", "memory.resident.read", "memory.resident.supply", "journal.startup.read", "journal.startup.supply"].into_iter().map(|operation| subject_permission(&subject, operation)).collect::<Vec<_>>()),
|
||||
"delegation":child_permissions.clone()})))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
@@ -3543,7 +3550,7 @@ async fn connected_two_subjects_delegate_distinct_tickets_concurrently_on_separa
|
||||
.human(
|
||||
reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||
json!({"operation_id":format!("parallel-{label}-policy"), "expected_change_id":null,
|
||||
json!({"operation_id":format!("parallel-{label}-policy"), "expected_change_id":subjects.subject_permissions(&subject).unwrap().unwrap().change_id,
|
||||
"execution":execution, "delegation":permissions}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -132,7 +132,12 @@ async fn deletion_owner_scope_exact_retry_and_subject_isolation() {
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("fresh").unwrap())
|
||||
.unwrap();
|
||||
assert_ne!(fresh.id, subject.id);
|
||||
assert!(store.subject_permissions(&fresh.id).unwrap().is_none());
|
||||
let source = store.subject_permissions(&fresh.id).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
source.execution,
|
||||
server_api::authorization::subject_conversation_permissions(TEST_WORKSPACE_ID, &fresh.id)
|
||||
);
|
||||
assert!(source.delegation.is_empty());
|
||||
assert_eq!(
|
||||
store
|
||||
.pending_staging_candidates(&fresh.id, 100)
|
||||
|
||||
@@ -74,7 +74,9 @@ fn update(screen: &Value, operation: &str, execution: Value, delegation: Value)
|
||||
async fn management_read_exposes_operation_use_without_duplicate_worker_target_grants() {
|
||||
let f = ManagementFixture::new().await;
|
||||
let screen = f.read().await;
|
||||
assert!(screen["permissions"].is_null());
|
||||
assert!(screen["permissions"]["change_id"].is_string());
|
||||
assert_eq!(screen["permissions"]["delegation"], json!([]));
|
||||
f.assert_current(&screen["permissions"]);
|
||||
assert_eq!(screen["can_manage"], true);
|
||||
let operations = screen["operations"].as_array().expect("management catalog");
|
||||
let create = operations
|
||||
@@ -348,7 +350,7 @@ async fn read_only_account_cannot_save_or_replay_an_owner_permission_change() {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
|
||||
f.assert_current(&Value::Null);
|
||||
f.assert_current(&initial["permissions"]);
|
||||
let saved = f.save(input.clone()).await;
|
||||
let (status, body) = response(
|
||||
&f.api,
|
||||
@@ -398,8 +400,8 @@ async fn new_unknown_legacy_unavailable_and_operation_resource_mismatch_are_reje
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{name}/{lane}: {body}");
|
||||
assert!(body["message"].is_string(), "{body}");
|
||||
f.assert_current(&Value::Null);
|
||||
assert!(f.read().await["permissions"].is_null());
|
||||
f.assert_current(&screen["permissions"]);
|
||||
assert_eq!(f.read().await["permissions"], screen["permissions"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,7 +419,14 @@ async fn unsupported_saved_entries_can_only_be_retained_in_the_same_lane_or_remo
|
||||
&f.subject,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "seed-pre-catalog-policy".into(),
|
||||
expected_change_id: None,
|
||||
expected_change_id: Some(
|
||||
open_subjektiv_store(&f.api)
|
||||
.unwrap()
|
||||
.subject_permissions(&f.subject)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id,
|
||||
),
|
||||
execution: execution.clone(),
|
||||
delegation: delegation.clone(),
|
||||
},
|
||||
|
||||
@@ -23,7 +23,13 @@ async fn subject_callbacks_finish_before_waiting_host_parents_release_capacity()
|
||||
&subject.id,
|
||||
&server_api::authorization::SubjectPermissionsUpdate {
|
||||
operation_id: format!("subject-callback-grant-{index}"),
|
||||
expected_change_id: None,
|
||||
expected_change_id: Some(
|
||||
subjektiv
|
||||
.subject_permissions(&subject.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id,
|
||||
),
|
||||
execution: [
|
||||
"memory.resident.read",
|
||||
"session.record",
|
||||
|
||||
@@ -1011,6 +1011,18 @@ async fn unset_subject_consolidation_denies_before_job_dispatch() {
|
||||
.create_subject(SubjectRole::new("unset-job-policy").unwrap())
|
||||
.unwrap();
|
||||
let source = attach_memory_job_source(&api, &subject.id).await;
|
||||
// A legacy unset policy has no source pointer; retain the initial history fixture.
|
||||
api.feature_storage
|
||||
.open(&api.subjektiv_registration)
|
||||
.unwrap()
|
||||
.with_connection(|c| {
|
||||
c.execute(
|
||||
"DELETE FROM subject_permissions WHERE subject_id=?1",
|
||||
[&subject.id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.subject_permissions(&subject.id).unwrap().is_none());
|
||||
stage(&store, &subject.id, "untouched");
|
||||
let error = start_subjektiv_staging_consolidation(
|
||||
|
||||
@@ -432,37 +432,10 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup()
|
||||
let subject = store
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("connected-handoff").unwrap())
|
||||
.unwrap();
|
||||
let owner =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "connected-journal-owner").await;
|
||||
// This fixture exercises Journal continuity, not unset-policy refusal. Assign
|
||||
// the companion's required Subject operations through authenticated administration;
|
||||
// no filesystem, shell or delegation permission is granted to its body.
|
||||
let update = server_api::authorization::SubjectPermissionsUpdate {
|
||||
operation_id: "connected-journal-activation".into(),
|
||||
expected_change_id: None,
|
||||
execution: connected_subject_permissions(&subject.id),
|
||||
delegation: Vec::new(),
|
||||
};
|
||||
let response = build_router(api.clone())
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions",
|
||||
subject.id
|
||||
))
|
||||
.header("authorization", format!("Bearer {owner}"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&update).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), 2 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
||||
// Exercise creation-time authority through real recording and startup callbacks,
|
||||
// without a management grant or an execution/delegation policy replacement.
|
||||
let initial_policy = store.subject_permissions(&subject.id).unwrap().unwrap();
|
||||
assert!(initial_policy.delegation.is_empty());
|
||||
let old = connected_create(&api, &subject.id, "connected-old-body").await;
|
||||
let old_attr = store
|
||||
.subject_session_attributions(&subject.id)
|
||||
@@ -766,6 +739,11 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup()
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
store.subject_permissions(&subject.id).unwrap(),
|
||||
Some(initial_policy),
|
||||
"recording and startup must not rewrite the initial policy"
|
||||
);
|
||||
for (worker, session) in [
|
||||
(&old, old_attr.session_id.as_str()),
|
||||
(&next, old_attr.session_id.as_str()),
|
||||
@@ -1622,7 +1600,13 @@ async fn connected_restore_fixture_with_pending_head(
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "restore-host-auth-owner").await;
|
||||
let update = server_api::authorization::SubjectPermissionsUpdate {
|
||||
operation_id: "restore-host-auth-permissions".into(),
|
||||
expected_change_id: None,
|
||||
expected_change_id: Some(
|
||||
store
|
||||
.subject_permissions(&subject.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id,
|
||||
),
|
||||
execution: connected_subject_permissions(&subject.id),
|
||||
delegation: vec![],
|
||||
};
|
||||
|
||||
@@ -45,7 +45,13 @@ impl Fixture {
|
||||
subject_id,
|
||||
&server_api::authorization::SubjectPermissionsUpdate {
|
||||
operation_id: format!("memory-journal-fixture-grant-{subject_id}"),
|
||||
expected_change_id: None,
|
||||
expected_change_id: Some(
|
||||
store
|
||||
.subject_permissions(subject_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id,
|
||||
),
|
||||
execution: [
|
||||
"journal.memory.links.reconcile",
|
||||
"journal.memory.links.read",
|
||||
@@ -83,6 +89,19 @@ impl Fixture {
|
||||
.unwrap();
|
||||
if granted {
|
||||
Self::grant_permissions(&store, &subject.id);
|
||||
} else {
|
||||
// Model an existing Subject with unset policy, retaining creation history.
|
||||
api.feature_storage
|
||||
.open(&api.subjektiv_registration)
|
||||
.unwrap()
|
||||
.with_connection(|connection| {
|
||||
connection.execute(
|
||||
"DELETE FROM subject_permissions WHERE subject_id = ?1",
|
||||
[&subject.id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let worker = Self::body_worker(&api, &subject.id);
|
||||
let source_worker = RuntimeWorkerRef::new(RUNTIME, Uuid::new_v4().to_string());
|
||||
@@ -802,6 +821,8 @@ async fn memory_journal_unset_or_revoked_permissions_deny_every_route_without_li
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
assert!(f.store.subject_permissions(&subject).unwrap().is_none());
|
||||
}
|
||||
let count_links = || {
|
||||
f.api
|
||||
|
||||
@@ -551,7 +551,7 @@ async fn public_two_subject_ticket_spawn_notify_edit_result_retry_and_fences() {
|
||||
]);
|
||||
let (status, policy) = harness.human(reqwest::Method::POST,
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||
json!({"operation_id":format!("public-{label}-policy"),"expected_change_id":null,"execution":execution,"delegation":delegation})).await;
|
||||
json!({"operation_id":format!("public-{label}-policy"),"expected_change_id":subjects.subject_permissions(&subject).unwrap().unwrap().change_id,"execution":execution,"delegation":delegation})).await;
|
||||
assert_eq!(status, StatusCode::OK, "{policy}");
|
||||
let change = subjects
|
||||
.subject_permissions(&subject)
|
||||
|
||||
@@ -385,7 +385,7 @@ async fn connected_roundtrip(explicit_workdir: bool, repair: bool) {
|
||||
operation("worker.stop"),
|
||||
operation("worker.observe"),
|
||||
]);
|
||||
let (status, assigned) = response(api, human_request(&harness.owner,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"public-messaging-policy","expected_change_id":null,"execution":execution,"delegation":permissions.lock().unwrap().clone()})))).await;
|
||||
let (status, assigned) = response(api, human_request(&harness.owner,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"public-messaging-policy","expected_change_id":store.subject_permissions(&subject).unwrap().unwrap().change_id,"execution":execution,"delegation":permissions.lock().unwrap().clone()})))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{assigned}");
|
||||
let before = store.subject_permissions(&subject).unwrap().unwrap();
|
||||
if repair {
|
||||
|
||||
@@ -826,7 +826,13 @@ async fn create_and_restore_session_callbacks_finish_while_deletion_start_waits(
|
||||
&subject.id,
|
||||
&server_api::authorization::SubjectPermissionsUpdate {
|
||||
operation_id: "session-callback-grant".into(),
|
||||
expected_change_id: None,
|
||||
expected_change_id: Some(
|
||||
subjektiv
|
||||
.subject_permissions(&subject.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id,
|
||||
),
|
||||
execution: vec![server_api::authorization::ScopedPermission {
|
||||
operation: "session.record".into(),
|
||||
resource: server_api::authorization::PermissionResource::Subject {
|
||||
|
||||
@@ -178,7 +178,11 @@ impl Fixture {
|
||||
Self::request(
|
||||
"POST",
|
||||
SUBJECTS,
|
||||
serde_json::json!({ "role": role, "behavior_md": "" }),
|
||||
serde_json::json!({
|
||||
"operation_id": format!("create-{role}"),
|
||||
"role": role,
|
||||
"behavior_md": ""
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1118,6 +1122,7 @@ async fn subjektiv_runtime_waits_leave_db_and_control_free_and_cancel_retains_th
|
||||
workspace_id: WORKSPACE.into(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectCreateRequest {
|
||||
operation_id: format!("create-network-subject-{kind}"),
|
||||
role: "network subject".into(),
|
||||
behavior_md: String::new(),
|
||||
}),
|
||||
@@ -1136,7 +1141,14 @@ async fn subjektiv_runtime_waits_leave_db_and_control_free_and_cancel_retains_th
|
||||
&subject.id,
|
||||
&server_api::authorization::SubjectPermissionsUpdate {
|
||||
operation_id: format!("session-wait-grant-{kind}"),
|
||||
expected_change_id: None,
|
||||
expected_change_id: Some(
|
||||
open_subjektiv_store(&fixture.api)
|
||||
.unwrap()
|
||||
.subject_permissions(&subject.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.change_id,
|
||||
),
|
||||
execution: vec![server_api::authorization::ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: server_api::authorization::PermissionResource::Subject {
|
||||
|
||||
@@ -15451,11 +15451,16 @@
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
"operation_id": {
|
||||
"description": "Stable across exact retries; changed input with the same ID conflicts.",
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operation_id",
|
||||
"role"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -22720,7 +22725,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224"
|
||||
"x-yoi-source-digest": "sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
enabled = true;
|
||||
chat = { enabled = true; };
|
||||
consolidation_tools = false;
|
||||
extraction = { enabled = false; };
|
||||
extraction = { enabled = true; threshold = 50000; };
|
||||
};
|
||||
web = { enabled = false; };
|
||||
image = { enabled = false; };
|
||||
@@ -54,6 +54,8 @@
|
||||
{ tool = "SubjektivMemoryQuery"; pattern = "*"; action = "allow"; },
|
||||
{ tool = "SubjektivMemoryRead"; pattern = "*"; action = "allow"; },
|
||||
{ tool = "SubjektivMemoryListChanges"; pattern = "*"; action = "allow"; },
|
||||
{ tool = "SubjektivMemoryRemember"; pattern = "*"; action = "allow"; },
|
||||
{ tool = "SubjektivMemoryProposeChange"; pattern = "*"; action = "allow"; },
|
||||
{ tool = "QueryObjective"; pattern = "*"; action = "allow"; },
|
||||
{ tool = "ShowObjective"; pattern = "*"; action = "allow"; },
|
||||
{ tool = "QueryTicket"; pattern = "*"; action = "allow"; },
|
||||
|
||||
@@ -43,6 +43,12 @@ const memoryPageCursor = "fixture-memory-page-2";
|
||||
const changePageCursor = "fixture-change-page-2";
|
||||
const createdSubjectIds = new Set<string>();
|
||||
const subjectCreateRequests: unknown[] = [];
|
||||
type SubjectCreateInput = { operation_id: string; role: string; behavior_md: string };
|
||||
const subjectCreateReceipts = new Map<
|
||||
string,
|
||||
{ request: SubjectCreateInput; subject: ReturnType<typeof subject> }
|
||||
>();
|
||||
const subjectCreateOutcomes: { outcome: string; subject_id?: string }[] = [];
|
||||
let createdSubjectCount = 0;
|
||||
|
||||
const paragraphs = Array.from(
|
||||
@@ -390,6 +396,21 @@ async function staticResponse(pathname: string): Promise<Response> {
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
// 合成HTTPの閲覧主体。実Backendの認証や権限付与の証拠にはしない。
|
||||
if (url.pathname === "/api/auth/whoami") {
|
||||
return json({
|
||||
authentication_reference: "memory-review-fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: {
|
||||
user_id: "fixture-user",
|
||||
account_id: "owner",
|
||||
handle: "fixture",
|
||||
display_name: "Synthetic Memory owner",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/hosting/access") return json({ is_admin: false });
|
||||
if (permissionsMode) {
|
||||
const response = await permissionsFixture(request);
|
||||
if (response) return response;
|
||||
@@ -397,6 +418,14 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
if (url.pathname === "/fixture/subject-create-requests") {
|
||||
return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests });
|
||||
}
|
||||
if (url.pathname === "/fixture/subject-create-evidence") {
|
||||
return json({
|
||||
authority: "synthetic-http-NOT-real-Backend",
|
||||
requests: subjectCreateRequests,
|
||||
outcomes: subjectCreateOutcomes,
|
||||
created: subjects.filter((item) => createdSubjectIds.has(item.id)),
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/workspaces") {
|
||||
return json([{
|
||||
workspace_id: workspaceId,
|
||||
@@ -488,22 +517,47 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
if (url.pathname === subjectsPath) {
|
||||
if (request.method === "POST") {
|
||||
const payload = await request.json().catch(() => null) as {
|
||||
operation_id?: unknown;
|
||||
role?: unknown;
|
||||
behavior_md?: unknown;
|
||||
} | null;
|
||||
subjectCreateRequests.push(payload);
|
||||
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
|
||||
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
|
||||
const reject = (message: string, status = 400) => {
|
||||
subjectCreateOutcomes.push({ outcome: `rejected-${status}` });
|
||||
return json({ error: status === 409 ? "Conflict" : "Bad Request", message }, status);
|
||||
};
|
||||
if (
|
||||
!payload || typeof payload.operation_id !== "string" ||
|
||||
payload.operation_id.trim().length === 0
|
||||
) return reject("subject create operation_id is required");
|
||||
if (typeof payload.role !== "string" || payload.role.trim().length === 0) {
|
||||
return reject("subject role must not be empty");
|
||||
}
|
||||
if (payload.role === "Permission denied") {
|
||||
subjectCreateOutcomes.push({ outcome: "rejected-403" });
|
||||
return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
|
||||
}
|
||||
if (typeof payload.behavior_md !== "string") {
|
||||
return json({ error: "Bad Request", message: "subject behavior must be a string" }, 400);
|
||||
return reject("subject behavior must be a string");
|
||||
}
|
||||
const input: SubjectCreateInput = {
|
||||
operation_id: payload.operation_id,
|
||||
role: payload.role,
|
||||
behavior_md: payload.behavior_md,
|
||||
};
|
||||
const receipt = subjectCreateReceipts.get(input.operation_id);
|
||||
if (receipt) {
|
||||
if (
|
||||
receipt.request.role !== input.role || receipt.request.behavior_md !== input.behavior_md
|
||||
) return reject("operation_id already used with different input", 409);
|
||||
subjectCreateOutcomes.push({ outcome: "replay", subject_id: receipt.subject.id });
|
||||
return json(receipt.subject, 201);
|
||||
}
|
||||
createdSubjectCount += 1;
|
||||
const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`;
|
||||
const created = subject(id, payload.role, "active", "fingerprint-empty", payload.behavior_md);
|
||||
const created = subject(id, input.role, "active", "fingerprint-empty", input.behavior_md);
|
||||
subjectCreateReceipts.set(input.operation_id, { request: input, subject: created });
|
||||
subjectCreateOutcomes.push({ outcome: "created", subject_id: id });
|
||||
createdSubjectIds.add(id);
|
||||
subjects.unshift(created);
|
||||
return json(created, 201);
|
||||
|
||||
@@ -482,11 +482,25 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
||||
]);
|
||||
const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`))
|
||||
.json();
|
||||
const operationIds = createEvidence.requests.map((request: { operation_id: string }) =>
|
||||
request.operation_id
|
||||
);
|
||||
for (const operationId of operationIds) {
|
||||
assert(
|
||||
typeof operationId === "string" &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
operationId,
|
||||
),
|
||||
"Each new creation attempt must carry a UUID operation_id",
|
||||
);
|
||||
}
|
||||
assert(operationIds[0] !== operationIds[1], "A resolved rejection must allow a new attempt");
|
||||
assertEquals(createEvidence, {
|
||||
count: 2,
|
||||
requests: [
|
||||
{ role: "Permission denied", behavior_md: "" },
|
||||
{ operation_id: operationIds[0], role: "Permission denied", behavior_md: "" },
|
||||
{
|
||||
operation_id: operationIds[1],
|
||||
role: " Release steward ",
|
||||
behavior_md: "Challenge unsupported assumptions before release.",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env -S deno run -A
|
||||
/** 実Webの作成・再送を合成HTTP境界で検証する。実Backendの保存・認可は証明しない。 */
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { dirname, fromFileUrl, join, relative, resolve } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { sha256File } from "../src/artifacts.ts";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts";
|
||||
|
||||
const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||
const [buildArg, runId, modeArg] = Deno.args;
|
||||
const mode = modeArg ?? (runId?.startsWith("before") ? "before" : "after");
|
||||
if (
|
||||
!buildArg || !runId || !/^[a-zA-Z0-9._-]+$/.test(runId) || !["before", "after"].includes(mode)
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: subject_creation_review.ts <actual-build-path> <unique-run-id> [before|after]",
|
||||
);
|
||||
}
|
||||
const buildRoot = resolve(buildArg);
|
||||
for (const name of ["index.html", "_app/immutable"]) {
|
||||
await Deno.stat(join(buildRoot, name)).catch(() => {
|
||||
throw new Error(`Actual Web build missing: ${join(buildRoot, name)}; parent must supply it.`);
|
||||
});
|
||||
}
|
||||
const output = join(root, "target/web-ux/t806", runId);
|
||||
await Deno.mkdir(dirname(output), { recursive: true });
|
||||
await Deno.mkdir(output); // 既存runの画像や判断を上書きしない。
|
||||
|
||||
async function git(args: string[]) {
|
||||
const result = await new Deno.Command("git", { args, cwd: root, stdout: "piped" }).output();
|
||||
return result.success ? new TextDecoder().decode(result.stdout).trim() : null;
|
||||
}
|
||||
async function inventory(path: string): Promise<{ path: string; sha256: string }[]> {
|
||||
const files: { path: string; sha256: string }[] = [];
|
||||
for await (const entry of Deno.readDir(path)) {
|
||||
const child = join(path, entry.name);
|
||||
if (entry.isDirectory) files.push(...await inventory(child));
|
||||
else if (entry.isFile) {
|
||||
files.push({ path: relative(buildRoot, child), sha256: await sha256File(child) });
|
||||
}
|
||||
}
|
||||
return files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
const buildFiles = await inventory(buildRoot);
|
||||
const sourceStatus = await git(["status", "--porcelain"]);
|
||||
const manifest = {
|
||||
evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend",
|
||||
mode,
|
||||
buildRoot,
|
||||
buildFiles,
|
||||
source: {
|
||||
commitSha: await git(["rev-parse", "HEAD"]),
|
||||
dirty: sourceStatus === null ? null : sourceStatus.length > 0,
|
||||
status: sourceStatus,
|
||||
captureSources: await Promise.all([
|
||||
"tools/web-ux/browser-tests/subject_creation_review.ts",
|
||||
"tools/web-ux/browser-tests/memory_document_fixture_server.ts",
|
||||
"tools/web-ux/browser-tests/memory_document_harness.ts",
|
||||
].map(async (path) => ({ path, sha256: await sha256File(join(root, path)) }))),
|
||||
buildProvenance:
|
||||
"parent-supplied build; checkout metadata is capture-time, not a build attestation",
|
||||
},
|
||||
visualVerdict: "UNREVIEWED — parent must ViewImage and inspect review-context.json",
|
||||
status: "running",
|
||||
browser: { name: "chromium", version: "" },
|
||||
expectedFaults: [
|
||||
"Per width/theme: first POST reply aborted only after synthetic persistence (HTTP 201)",
|
||||
...(mode === "after"
|
||||
? ["Per width/theme: second POST intentionally returns HTTP 403 before fixture"]
|
||||
: []),
|
||||
],
|
||||
baselineLimit: mode === "before"
|
||||
? "Capture only. Missing operation_id is added ONLY to fixture transport for synthetic persistence; original browser body is recorded. No replay or disabled-input success asserted."
|
||||
: null,
|
||||
captures: [] as unknown[],
|
||||
scenarios: [] as unknown[],
|
||||
failures: [] as string[],
|
||||
};
|
||||
async function saveManifest() {
|
||||
await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
type Fault = {
|
||||
kind: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
expected: boolean;
|
||||
};
|
||||
function observe(page: Page, createUrl: string) {
|
||||
const faults: Fault[] = [];
|
||||
let documentStatus: number | null = null;
|
||||
let allowAbort = false;
|
||||
let allow403 = false;
|
||||
page.on(
|
||||
"pageerror",
|
||||
(e) => faults.push({ kind: "pageerror", message: e.stack ?? e.message, expected: false }),
|
||||
);
|
||||
page.on("console", (e) => {
|
||||
if (!["warning", "error"].includes(e.type())) return;
|
||||
const url = e.location().url;
|
||||
// 意図した同一endpointのネットワーク診断だけを許容し、pageerrorは常に失敗にする。
|
||||
const expected = url === createUrl && (
|
||||
(allowAbort && /Failed to load resource: net::ERR_FAILED/.test(e.text())) ||
|
||||
(allow403 && /Failed to load resource:.*403/.test(e.text()))
|
||||
);
|
||||
faults.push({ kind: `console-${e.type()}`, message: e.text(), url, expected });
|
||||
});
|
||||
page.on("requestfailed", (r) =>
|
||||
faults.push({
|
||||
kind: "requestfailed",
|
||||
message: r.failure()?.errorText ?? "request failed",
|
||||
url: r.url(),
|
||||
method: r.method(),
|
||||
expected: allowAbort && r.url() === createUrl && r.method() === "POST" &&
|
||||
r.failure()?.errorText === "net::ERR_FAILED",
|
||||
}));
|
||||
page.on("response", (r) => {
|
||||
if (r.request().isNavigationRequest()) documentStatus = r.status();
|
||||
if (r.status() >= 400) {
|
||||
faults.push({
|
||||
kind: "http",
|
||||
message: `HTTP ${r.status()}`,
|
||||
url: r.url(),
|
||||
method: r.request().method(),
|
||||
expected: allow403 && r.url() === createUrl && r.request().method() === "POST" &&
|
||||
r.status() === 403,
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
faults,
|
||||
documentStatus: () => documentStatus,
|
||||
expectAbort: () => {
|
||||
allowAbort = true;
|
||||
},
|
||||
expect403: () => {
|
||||
allow403 = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
async function controls(page: Page) {
|
||||
const role = page.getByRole("textbox", { name: "Role", exact: true });
|
||||
const behavior = page.getByRole("textbox", { name: /Behavior/ });
|
||||
if (!await role.count()) return null;
|
||||
return {
|
||||
role: await role.inputValue(),
|
||||
behavior: await behavior.inputValue(),
|
||||
roleDisabled: await role.isDisabled(),
|
||||
behaviorDisabled: await behavior.isDisabled(),
|
||||
submitting: await page.locator("#subject-create-panel").getAttribute("aria-busy"),
|
||||
};
|
||||
}
|
||||
async function capture(page: Page, observation: ReturnType<typeof observe>, id: string) {
|
||||
const image = join(output, `${id}.png`);
|
||||
const snapshot = join(output, `${id}.aria.txt`);
|
||||
await page.screenshot({ path: image });
|
||||
await Deno.writeTextFile(snapshot, await page.locator("body").ariaSnapshot());
|
||||
manifest.captures.push({
|
||||
id,
|
||||
route: new URL(page.url()).pathname,
|
||||
viewport: page.viewportSize(),
|
||||
theme: await page.evaluate(() =>
|
||||
(globalThis as any).matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||
),
|
||||
documentStatus: observation.documentStatus(),
|
||||
visibleAlerts: await page.getByRole("alert").allTextContents(),
|
||||
controls: await controls(page),
|
||||
focus: await page.locator(":focus").evaluateAll((elements) =>
|
||||
elements.map((e) => ({ tag: e.tagName, text: e.textContent, id: e.id }))
|
||||
),
|
||||
mainBounds: await page.locator("main").evaluate((e) => ({
|
||||
width: e.clientWidth,
|
||||
scrollTop: e.scrollTop,
|
||||
})),
|
||||
image,
|
||||
imageSha256: await sha256File(image),
|
||||
accessibilitySnapshot: snapshot,
|
||||
accessibilitySha256: await sha256File(snapshot),
|
||||
faults: [...observation.faults],
|
||||
unexpectedFaults: observation.faults.filter((f) => !f.expected),
|
||||
reviewed: false,
|
||||
});
|
||||
await saveManifest();
|
||||
await assertNoPageWideOverflow(page);
|
||||
assertEquals(observation.documentStatus(), 200, `${id}: document must render successfully`);
|
||||
}
|
||||
const roleText = " Release steward — evidence and uncertainty ";
|
||||
const behaviorText =
|
||||
"Challenge unsupported assumptions before release.\nAsk before irreversible actions; preserve the exact pending request.\nEvidence: https://example.com/review/subject-creation";
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
async function assertPending(page: Page) {
|
||||
assertEquals(await controls(page), {
|
||||
role: roleText,
|
||||
behavior: behaviorText,
|
||||
roleDisabled: true,
|
||||
behaviorDisabled: true,
|
||||
submitting: "false",
|
||||
});
|
||||
assert(await page.getByRole("button", { name: "Create Subject", exact: true }).isEnabled());
|
||||
}
|
||||
async function reopen(page: Page) {
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await page.locator("#subject-create-panel").waitFor({ state: "detached" });
|
||||
assert(
|
||||
await page.getByRole("button", { name: "New Subject", exact: true }).evaluate((e) =>
|
||||
e === e.ownerDocument.activeElement
|
||||
),
|
||||
);
|
||||
await page.getByRole("button", { name: "New Subject", exact: true }).click();
|
||||
await page.locator('#subject-create-panel[aria-busy="false"]').waitFor();
|
||||
}
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
manifest.browser.version = browser.version();
|
||||
try {
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const prefix = `${theme}-${width}`;
|
||||
const port = await freePort();
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const createUrl = `${base}/api/w/memory-review/subjektiv/subjects`;
|
||||
const owned = await startOwnedProcesses(
|
||||
[{
|
||||
id: `subject-create-${prefix}`,
|
||||
command: Deno.execPath(),
|
||||
cwd: join(root, "tools/web-ux"),
|
||||
args: [
|
||||
"run",
|
||||
"-A",
|
||||
join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"),
|
||||
String(port),
|
||||
buildRoot,
|
||||
"3",
|
||||
],
|
||||
readyUrl: `${base}/health`,
|
||||
}],
|
||||
join(output, "scenario.json"),
|
||||
join(output, `logs-${prefix}`),
|
||||
[],
|
||||
);
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(15_000);
|
||||
const observation = observe(page, createUrl);
|
||||
const requests: unknown[] = [];
|
||||
const transportOverrides: unknown[] = [];
|
||||
const scenario = {
|
||||
prefix,
|
||||
requests,
|
||||
transportOverrides,
|
||||
syntheticEvidence: null as unknown,
|
||||
faults: observation.faults,
|
||||
};
|
||||
manifest.scenarios.push(scenario);
|
||||
try {
|
||||
await page.goto(`${base}/w/memory-review/memory`);
|
||||
await page.getByRole("heading", { name: "Subjects", level: 1, exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "New Subject", exact: true }).click();
|
||||
const role = page.getByRole("textbox", { name: "Role", exact: true });
|
||||
const behavior = page.getByRole("textbox", { name: /Behavior/ });
|
||||
await role.fill(roleText);
|
||||
await behavior.fill(behaviorText);
|
||||
await role.focus();
|
||||
await capture(page, observation, `${prefix}-normal-form`);
|
||||
await page.keyboard.press("Tab");
|
||||
assert(await behavior.evaluate((e) => e === e.ownerDocument.activeElement));
|
||||
await page.keyboard.press("Tab");
|
||||
assert(
|
||||
await page.getByRole("button", { name: "Create Subject", exact: true }).evaluate((e) =>
|
||||
e === e.ownerDocument.activeElement
|
||||
),
|
||||
);
|
||||
await capture(page, observation, `${prefix}-normal-actions-keyboard`);
|
||||
|
||||
let attempt = 0;
|
||||
await page.route(createUrl, async (route) => {
|
||||
if (route.request().method() !== "POST") return await route.continue();
|
||||
attempt += 1;
|
||||
const body = route.request().postDataJSON();
|
||||
requests.push(body);
|
||||
if (attempt === 1) {
|
||||
observation.expectAbort();
|
||||
// 旧buildの比較撮影だけ、fixtureへ送るコピーにoperation_idを足す。
|
||||
// 実browser要求はrequestsに未変更で保存し、旧UIの契約成功とは扱わない。
|
||||
const bridged = mode === "before" && !body.operation_id;
|
||||
const transport = bridged ? { ...body, operation_id: crypto.randomUUID() } : body;
|
||||
if (bridged) {
|
||||
transportOverrides.push({
|
||||
attempt,
|
||||
reason: "baseline-only fixture bridge",
|
||||
body: transport,
|
||||
});
|
||||
}
|
||||
const persisted = await route.fetch({ postData: JSON.stringify(transport) });
|
||||
assertEquals(
|
||||
persisted.status(),
|
||||
201,
|
||||
"Fault must follow synthetic persistence, not validation rejection",
|
||||
);
|
||||
await persisted.dispose();
|
||||
await route.abort("failed");
|
||||
} else if (attempt === 2 && mode === "after") {
|
||||
observation.expect403();
|
||||
await route.fulfill({
|
||||
status: 403,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
error: "Forbidden",
|
||||
message: "synthetic later permission rejection",
|
||||
}),
|
||||
});
|
||||
} else await route.continue();
|
||||
});
|
||||
await page.getByRole("button", { name: "Create Subject", exact: true }).click();
|
||||
await page.getByRole("alert").filter({ hasText: /unknown|may have been created/i })
|
||||
.waitFor();
|
||||
await page.locator('#subject-create-panel[aria-busy="false"]').waitFor();
|
||||
await page.getByRole("alert").scrollIntoViewIfNeeded();
|
||||
await capture(page, observation, `${prefix}-unknown-after-persisted-response-loss`);
|
||||
await role.scrollIntoViewIfNeeded();
|
||||
await capture(page, observation, `${prefix}-unknown-fields`);
|
||||
if (mode === "after") {
|
||||
await assertPending(page);
|
||||
const first = requests[0] as { operation_id: string; role: string; behavior_md: string };
|
||||
assert(uuid.test(first.operation_id), "UI must generate a UUID per creation attempt");
|
||||
assertEquals(first, {
|
||||
operation_id: first.operation_id,
|
||||
role: roleText,
|
||||
behavior_md: behaviorText,
|
||||
});
|
||||
await reopen(page);
|
||||
await assertPending(page);
|
||||
await page.getByRole("alert").scrollIntoViewIfNeeded();
|
||||
await capture(page, observation, `${prefix}-cancel-reopen-pending`);
|
||||
await page.getByRole("button", { name: "Create Subject", exact: true }).click();
|
||||
await page.getByRole("alert").filter({ hasText: /do not have permission/ }).waitFor();
|
||||
await page.locator('#subject-create-panel[aria-busy="false"]').waitFor();
|
||||
assertEquals(
|
||||
requests[1],
|
||||
first,
|
||||
"Retry after cancel/reopen must retain the complete request",
|
||||
);
|
||||
await assertPending(page);
|
||||
await page.getByRole("alert").scrollIntoViewIfNeeded();
|
||||
await capture(page, observation, `${prefix}-later-403-still-pending`);
|
||||
await role.scrollIntoViewIfNeeded();
|
||||
await capture(page, observation, `${prefix}-later-403-disabled-fields`);
|
||||
await reopen(page);
|
||||
await assertPending(page);
|
||||
await page.getByRole("alert").scrollIntoViewIfNeeded();
|
||||
await capture(page, observation, `${prefix}-later-403-reopened-pending`);
|
||||
await page.getByRole("button", { name: "Create Subject", exact: true }).click();
|
||||
await page.waitForURL("**/memory/created-subject-0001");
|
||||
await page.getByRole("heading", { name: roleText.trim(), level: 1, exact: true })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
requests,
|
||||
[first, first, first],
|
||||
"All retries must use the same operation and full input",
|
||||
);
|
||||
const article = page.getByRole("article", { name: "User-managed Subject behavior" });
|
||||
await article.waitFor();
|
||||
assert((await article.textContent())?.includes("preserve the exact pending request."));
|
||||
await page.getByText("No committed Memories yet.").waitFor();
|
||||
await capture(page, observation, `${prefix}-recovered-actual-detail`);
|
||||
}
|
||||
scenario.syntheticEvidence = await (await fetch(`${base}/fixture/subject-create-evidence`))
|
||||
.json();
|
||||
const evidence = scenario.syntheticEvidence as {
|
||||
created: { id: string; role: string; behavior_md: string }[];
|
||||
requests: unknown[];
|
||||
outcomes: unknown[];
|
||||
};
|
||||
assertEquals(evidence.created.length, 1, "Only one synthetic Subject may be created");
|
||||
assertEquals(evidence.created[0].role, roleText);
|
||||
assertEquals(evidence.created[0].behavior_md, behaviorText);
|
||||
if (mode === "after") {
|
||||
assertEquals(evidence.requests, [requests[0], requests[0]]);
|
||||
assertEquals(evidence.outcomes, [
|
||||
{ outcome: "created", subject_id: "created-subject-0001" },
|
||||
{ outcome: "replay", subject_id: "created-subject-0001" },
|
||||
]);
|
||||
assertEquals(observation.faults.filter((f) => f.kind === "http" && f.expected).length, 1);
|
||||
}
|
||||
assertEquals(
|
||||
observation.faults.filter((f) => f.kind === "requestfailed" && f.expected).length,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
observation.faults.filter((f) => !f.expected),
|
||||
[],
|
||||
`${prefix}: unexpected browser faults`,
|
||||
);
|
||||
await Deno.writeTextFile(
|
||||
join(output, `${prefix}-synthetic-http-evidence.json`),
|
||||
JSON.stringify(scenario, null, 2),
|
||||
);
|
||||
} catch (e) {
|
||||
manifest.failures.push(
|
||||
`${prefix}: ${e instanceof Error ? e.stack ?? e.message : String(e)}`,
|
||||
);
|
||||
await capture(page, observation, `${prefix}-failure`).catch((captureError) =>
|
||||
manifest.failures.push(`Failure capture: ${captureError}`)
|
||||
);
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
scenario.syntheticEvidence = await fetch(`${base}/fixture/subject-create-evidence`)
|
||||
.then((response) => response.json()).catch(() => scenario.syntheticEvidence);
|
||||
await Deno.writeTextFile(
|
||||
join(output, `${prefix}-synthetic-http-evidence.json`),
|
||||
JSON.stringify(scenario, null, 2),
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await context.close();
|
||||
} finally {
|
||||
await stopOwnedProcesses(owned);
|
||||
await saveManifest();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(
|
||||
await inventory(buildRoot),
|
||||
buildFiles,
|
||||
"Parent build changed during the capture matrix",
|
||||
);
|
||||
manifest.status = mode === "before"
|
||||
? "baseline-capture-only-visual-unreviewed"
|
||||
: "browser-assertions-passed-visual-unreviewed";
|
||||
} catch (e) {
|
||||
manifest.status = "failed";
|
||||
throw e;
|
||||
} finally {
|
||||
await browser.close();
|
||||
await saveManifest();
|
||||
console.log(join(output, "review-context.json"));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
||||
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -68,7 +68,7 @@ export type SubjectPermissionInterruption = { runtime_id: string, worker_id: str
|
||||
|
||||
export type SubjektivSubjectState = "active" | "retired";
|
||||
|
||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
||||
export type SubjektivSubjectCreateRequest = { operation_id: string, role: string, behavior_md?: string, };
|
||||
|
||||
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
||||
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
||||
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
||||
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
||||
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -117,6 +117,18 @@ export class SubjektivSubjectCreateError extends Error {
|
||||
export function validateSubjektivSubjectCreateRequest(
|
||||
request: SubjektivSubjectCreateRequest,
|
||||
): SubjektivSubjectCreateRequest {
|
||||
if (
|
||||
typeof request.operation_id !== "string" ||
|
||||
request.operation_id.length === 0 ||
|
||||
new TextEncoder().encode(request.operation_id).byteLength > 256 ||
|
||||
request.operation_id.trim() !== request.operation_id ||
|
||||
/[\p{Cc}*]/u.test(request.operation_id)
|
||||
) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
"Subject operation_id must be nonempty, at most 256 bytes, and contain no surrounding whitespace, control characters, or '*'.",
|
||||
);
|
||||
}
|
||||
if (request.role.trim().length === 0) {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"validation",
|
||||
@@ -135,7 +147,11 @@ export function validateSubjektivSubjectCreateRequest(
|
||||
}
|
||||
const behaviorMd = request.behavior_md ?? "";
|
||||
validateSubjectBehavior(behaviorMd);
|
||||
return { role: request.role, behavior_md: behaviorMd };
|
||||
return {
|
||||
operation_id: request.operation_id,
|
||||
role: request.role,
|
||||
behavior_md: behaviorMd,
|
||||
};
|
||||
}
|
||||
|
||||
function validateSubjectBehavior(behaviorMd: string): void {
|
||||
@@ -224,6 +240,12 @@ export async function deleteSubjektivSubject(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 呼出元は意図した作成ごとにoperation_idを一度だけ採番し、結果不明なら
|
||||
* 同じID・role・behavior_mdの要求全体を保持する。この関数は自動再試行しない。
|
||||
* 厳密な再送は同じSubjectを返し、同じIDで入力を変えると409になる。
|
||||
* 再送は権限の再付与や削除済みSubjectの復活ではない。
|
||||
*/
|
||||
export async function createSubjektivSubject(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
@@ -241,7 +263,7 @@ export async function createSubjektivSubject(
|
||||
} catch {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"unknown_outcome",
|
||||
"The request outcome is unknown. A Subject may have been created.",
|
||||
"The request outcome is unknown. A Subject may have been created. Retry only the same operation_id and full request.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,7 +278,7 @@ export async function createSubjektivSubject(
|
||||
}
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"unknown_outcome",
|
||||
"The request outcome is unknown. A Subject may have been created.",
|
||||
"The request outcome is unknown. A Subject may have been created. Retry only the same operation_id and full request.",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
@@ -275,7 +297,7 @@ export async function createSubjektivSubject(
|
||||
} catch {
|
||||
throw new SubjektivSubjectCreateError(
|
||||
"unknown_outcome",
|
||||
"The Subject may have been created, but the response could not be confirmed.",
|
||||
"The Subject may have been created, but the response could not be confirmed. Retry only the same operation_id and full request.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
createSubjektivSubject,
|
||||
SubjektivSubjectCreateError,
|
||||
} from '#lib/workspace/memory/api.ts';
|
||||
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
|
||||
import type { SubjektivSubjectCreateRequest, SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
|
||||
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
|
||||
import type { PageData as RouteData } from './$types';
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
let roleInvalid = $state(false);
|
||||
let behaviorInvalid = $state(false);
|
||||
let createOutcomeUnknown = $state(false);
|
||||
let createRequest = $state<SubjektivSubjectCreateRequest | null>(null);
|
||||
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
|
||||
let createButton = $state<HTMLButtonElement>();
|
||||
let roleInput = $state<HTMLInputElement>();
|
||||
@@ -42,12 +43,14 @@
|
||||
}
|
||||
|
||||
async function openCreateForm(): Promise<void> {
|
||||
if (submitting || createExpanded) return;
|
||||
createExpanded = true;
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
createdSubject = null;
|
||||
if (!createOutcomeUnknown) {
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createdSubject = null;
|
||||
}
|
||||
await tick();
|
||||
roleInput?.focus();
|
||||
}
|
||||
@@ -55,13 +58,16 @@
|
||||
async function cancelCreate(): Promise<void> {
|
||||
if (submitting) return;
|
||||
createExpanded = false;
|
||||
role = '';
|
||||
behaviorMd = '';
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
createdSubject = null;
|
||||
// 結果不明の要求は閉じても破棄せず、再度開いたときも厳密に再送する。
|
||||
if (!createOutcomeUnknown) {
|
||||
role = '';
|
||||
behaviorMd = '';
|
||||
createRequest = null;
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createdSubject = null;
|
||||
}
|
||||
await tick();
|
||||
createButton?.focus();
|
||||
}
|
||||
@@ -72,13 +78,15 @@
|
||||
createError = null;
|
||||
roleInvalid = false;
|
||||
behaviorInvalid = false;
|
||||
createOutcomeUnknown = false;
|
||||
try {
|
||||
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
|
||||
createRequest ??= {
|
||||
operation_id: crypto.randomUUID(),
|
||||
role,
|
||||
behavior_md: behaviorMd,
|
||||
});
|
||||
};
|
||||
const subject = await createSubjektivSubject(fetch, data.workspaceId, createRequest);
|
||||
createdSubject = subject;
|
||||
createOutcomeUnknown = false;
|
||||
try {
|
||||
await goto(subjectHref(subject.id));
|
||||
} catch {
|
||||
@@ -88,7 +96,8 @@
|
||||
if (error instanceof SubjektivSubjectCreateError) {
|
||||
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
|
||||
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
|
||||
createOutcomeUnknown = error.kind === 'unknown_outcome';
|
||||
// 再送の拒否だけでは、先の結果不明な作成が未実行だったとは確認できない。
|
||||
createOutcomeUnknown ||= error.kind === 'unknown_outcome';
|
||||
createError = error.status === 401 || error.status === 403
|
||||
? 'You do not have permission to create Subjects in this Workspace.'
|
||||
: error.message;
|
||||
@@ -102,6 +111,7 @@
|
||||
createError = 'The request outcome is unknown. A Subject may have been created.';
|
||||
}
|
||||
} finally {
|
||||
if (!createOutcomeUnknown && !createdSubject) createRequest = null;
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
@@ -161,7 +171,7 @@
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
required
|
||||
disabled={submitting || createdSubject !== null}
|
||||
disabled={submitting || createOutcomeUnknown || createdSubject !== null}
|
||||
onkeydown={createFormKeydown}
|
||||
aria-invalid={roleInvalid ? 'true' : undefined}
|
||||
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
|
||||
@@ -173,7 +183,7 @@
|
||||
bind:this={behaviorInput}
|
||||
bind:value={behaviorMd}
|
||||
rows="7"
|
||||
disabled={submitting || createdSubject !== null}
|
||||
disabled={submitting || createOutcomeUnknown || createdSubject !== null}
|
||||
onkeydown={createFormKeydown}
|
||||
aria-invalid={behaviorInvalid ? 'true' : undefined}
|
||||
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
|
||||
|
||||
@@ -271,24 +271,25 @@ Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||
|
||||
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
|
||||
assertEquals(
|
||||
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
|
||||
{ role: " Release coordinator ", behavior_md: "" },
|
||||
validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: " Release coordinator " }),
|
||||
{ operation_id: "create-attempt", role: " Release coordinator ", behavior_md: "" },
|
||||
);
|
||||
assertThrows(
|
||||
() => validateSubjektivSubjectCreateRequest({ role: " " }),
|
||||
() => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: " " }),
|
||||
"must not be empty",
|
||||
);
|
||||
assertThrows(
|
||||
() => validateSubjektivSubjectCreateRequest({ role: "line\nbreak" }),
|
||||
() => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: "line\nbreak" }),
|
||||
"control characters",
|
||||
);
|
||||
assertThrows(
|
||||
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
|
||||
() => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: "界".repeat(86) }),
|
||||
"at most 256 bytes",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
validateSubjektivSubjectCreateRequest({
|
||||
operation_id: "create-attempt",
|
||||
role: "Reviewer",
|
||||
behavior_md: " ".repeat(3),
|
||||
}),
|
||||
@@ -297,6 +298,7 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
|
||||
assertThrows(
|
||||
() =>
|
||||
validateSubjektivSubjectCreateRequest({
|
||||
operation_id: "create-attempt",
|
||||
role: "Reviewer",
|
||||
behavior_md: "x".repeat(16 * 1024 + 1),
|
||||
}),
|
||||
@@ -304,6 +306,35 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject create rejects invalid operation IDs before sending without rewriting them", async () => {
|
||||
let calls = 0;
|
||||
const fetchFn = (async () => {
|
||||
calls += 1;
|
||||
return Response.json(subject());
|
||||
}) as typeof fetch;
|
||||
for (const operationId of [
|
||||
undefined, null, 12, "", " ", " leading", "trailing ", "create*",
|
||||
"line\nbreak", "control\u007F", "x".repeat(257), "界".repeat(86),
|
||||
]) {
|
||||
const request = JSON.parse(JSON.stringify({
|
||||
operation_id: operationId,
|
||||
role: "Reviewer",
|
||||
}));
|
||||
await assertSubjectCreateRejects(
|
||||
() => createSubjektivSubject(fetchFn, "workspace", request),
|
||||
"validation",
|
||||
"Subject operation_id",
|
||||
);
|
||||
}
|
||||
assertEquals(calls, 0);
|
||||
for (const operationId of [crypto.randomUUID(), "x".repeat(256), "界".repeat(85)]) {
|
||||
assertEquals(
|
||||
validateSubjektivSubjectCreateRequest({ operation_id: operationId, role: "Reviewer" }),
|
||||
{ operation_id: operationId, role: "Reviewer", behavior_md: "" },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Subject behavior update uses CAS and preserves exact text", async () => {
|
||||
const requests: Array<{ path: string; init?: RequestInit }> = [];
|
||||
const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => {
|
||||
@@ -363,6 +394,7 @@ Deno.test("Subject create client sends the exact typed body once and parses the
|
||||
}) as typeof fetch;
|
||||
|
||||
const created = await createSubjektivSubject(fetchFn, "workspace one", {
|
||||
operation_id: "create-attempt",
|
||||
role: " Release coordinator ",
|
||||
behavior_md: "Stay concise.",
|
||||
});
|
||||
@@ -376,7 +408,7 @@ Deno.test("Subject create client sends the exact typed body once and parses the
|
||||
assertEquals(requests[0].init?.method, "POST");
|
||||
assertEquals(
|
||||
requests[0].init?.body,
|
||||
'{"role":" Release coordinator ","behavior_md":"Stay concise."}',
|
||||
'{"operation_id":"create-attempt","role":" Release coordinator ","behavior_md":"Stay concise."}',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -396,6 +428,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
createSubjektivSubject(rejectedFetch, "workspace-1", {
|
||||
operation_id: "create-attempt",
|
||||
role: "Reviewer",
|
||||
}),
|
||||
"rejected",
|
||||
@@ -412,6 +445,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
createSubjektivSubject(serverErrorFetch, "workspace-1", {
|
||||
operation_id: "create-attempt",
|
||||
role: "Reviewer",
|
||||
}),
|
||||
"unknown_outcome",
|
||||
@@ -428,6 +462,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
createSubjektivSubject(mismatchFetch, "workspace-1", {
|
||||
operation_id: "create-attempt",
|
||||
role: "Reviewer",
|
||||
}),
|
||||
"unknown_outcome",
|
||||
@@ -441,13 +476,65 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
||||
throw new TypeError("connection reset");
|
||||
}) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
||||
() => createSubjektivSubject(unknownFetch, "workspace-1", { operation_id: "create-attempt", role: "Reviewer" }),
|
||||
"unknown_outcome",
|
||||
"may have been created",
|
||||
);
|
||||
assertEquals(unknownCalls, 1);
|
||||
});
|
||||
|
||||
Deno.test("Subject create exact retry retains serialized operation ID and full input after unknown outcomes", async () => {
|
||||
for (const failure of ["transport", "server", "malformed", "mismatched"] as const) {
|
||||
const request = Object.freeze({
|
||||
operation_id: crypto.randomUUID(),
|
||||
role: " Release coordinator ",
|
||||
behavior_md: "Stay concise.\nPreserve exact text.\n",
|
||||
});
|
||||
const bodies: unknown[] = [];
|
||||
const created = { ...subject(), role: request.role, behavior_md: request.behavior_md };
|
||||
const fetchFn = (async (_path: string | URL | Request, init?: RequestInit) => {
|
||||
bodies.push(init?.body);
|
||||
if (bodies.length === 1) {
|
||||
if (failure === "transport") throw new TypeError("connection reset");
|
||||
if (failure === "server") return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
if (failure === "malformed") return new Response("not JSON", { status: 201 });
|
||||
return Response.json({ ...created, behavior_md: "Different input" }, { status: 201 });
|
||||
}
|
||||
return Response.json(created, { status: 201 });
|
||||
}) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() => createSubjektivSubject(fetchFn, "workspace", request),
|
||||
"unknown_outcome",
|
||||
"Retry only the same operation_id and full request",
|
||||
failure === "server" ? 500 : null,
|
||||
);
|
||||
assertEquals(bodies, [JSON.stringify(request)]);
|
||||
assertEquals(await createSubjektivSubject(fetchFn, "workspace", request), created);
|
||||
assertEquals(bodies, [JSON.stringify(request), JSON.stringify(request)]);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Subject create conflict and deleted-Subject rejection never trigger a fresh operation", async () => {
|
||||
for (const [status, message] of [
|
||||
[409, "operation_id already used with different input"],
|
||||
[404, "Subject was deleted"],
|
||||
] as const) {
|
||||
const request = { operation_id: crypto.randomUUID(), role: "Reviewer", behavior_md: "Changed input" };
|
||||
const bodies: unknown[] = [];
|
||||
const fetchFn = (async (_path: string | URL | Request, init?: RequestInit) => {
|
||||
bodies.push(init?.body);
|
||||
return Response.json({ message }, { status });
|
||||
}) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() => createSubjektivSubject(fetchFn, "workspace", request),
|
||||
"rejected",
|
||||
message,
|
||||
status,
|
||||
);
|
||||
assertEquals(bodies, [JSON.stringify(request)]);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Resident surface parser accepts ready-empty and enforces snapshot invariants", () => {
|
||||
const readyEmpty = {
|
||||
subject_id: "subject-1",
|
||||
|
||||
Reference in New Issue
Block a user