Compare commits
20
Commits
develop
...
dogfooding
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b12c3e085 | ||
|
|
623c31615d | ||
|
|
af719e387d | ||
|
|
d12bc2ea0b | ||
|
|
a1df3742b1 | ||
|
|
d8818b776b | ||
|
|
40c36ccb1d | ||
|
|
80c151d084 | ||
|
|
c6a8759e7b | ||
|
|
f17b5212c7 | ||
|
|
b284934e34 | ||
|
|
2d33321fab | ||
|
|
0975471986 | ||
|
|
214d6e21dd | ||
|
|
fca5c23345 | ||
|
|
f9b1500ba2 | ||
|
|
931bf3b27d | ||
|
|
6bb671be21 | ||
|
|
b8f2fa479c | ||
|
|
1639be9841 |
Generated
+6
@@ -6707,15 +6707,21 @@ name = "yoi-e2e"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"config-source",
|
||||
"libc",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"server-api",
|
||||
"session-store",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"tungstenite 0.29.0",
|
||||
"webauthn-authenticator-rs",
|
||||
"webauthn-rs-proto",
|
||||
"worker-runtime",
|
||||
"yoi-workspace-server",
|
||||
]
|
||||
|
||||
|
||||
@@ -1747,7 +1747,23 @@ fn ticket_event(
|
||||
) -> Result<(), MergeRequestError> {
|
||||
let ticket = &mr.ticket_ids[0];
|
||||
let n:i64=t.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,ticket],|r|r.get(0))?;
|
||||
t.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)VALUES(?1,?2,?3,'comment',?4,?5,NULL,NULL,'Merge Request integrated',?6)",params![mr.workspace_id,ticket,n,format!("worker:{}:{}",e.merged_by.runtime_id,e.merged_by.worker_id),e.created_at.to_rfc3339(),format!("Merge Request `{}` integrated approved source ref `{}`; Ticket remains in progress until guarded Ticket completion.",mr.merge_request_id,e.approved_source_ref)])?;
|
||||
t.execute(
|
||||
"INSERT INTO typed_ticket_events(
|
||||
workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body,event_ref
|
||||
) VALUES(?1,?2,?3,'comment',?4,?5,NULL,NULL,'Merge Request integrated',?6,?7)",
|
||||
params![
|
||||
mr.workspace_id,
|
||||
ticket,
|
||||
n,
|
||||
format!("worker:{}:{}", e.merged_by.runtime_id, e.merged_by.worker_id),
|
||||
e.created_at.to_rfc3339(),
|
||||
format!(
|
||||
"Merge Request `{}` integrated approved source ref `{}`; Ticket remains in progress until guarded Ticket completion.",
|
||||
mr.merge_request_id, e.approved_source_ref
|
||||
),
|
||||
format!("{ticket}:{n}"),
|
||||
],
|
||||
)?;
|
||||
for (k, v) in [
|
||||
("implementation_assignment_id", a),
|
||||
("merge_request_id", &mr.merge_request_id),
|
||||
|
||||
@@ -53,8 +53,8 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("db");
|
||||
let c = Connection::open(&p).unwrap();
|
||||
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
|
||||
c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('W','T',-1,'comment','Requirements')",[]).unwrap();
|
||||
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,event_ref TEXT NOT NULL DEFAULT '',PRIMARY KEY(workspace_id,ticket_id,event_index),UNIQUE(workspace_id,ticket_id,event_ref));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
|
||||
c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref) VALUES('W','T',-1,'comment','Requirements','T:-1')",[]).unwrap();
|
||||
drop(c);
|
||||
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
|
||||
assignment_id: "A".into(),
|
||||
@@ -457,6 +457,18 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
|
||||
.unwrap();
|
||||
assert_eq!(replayed, merged);
|
||||
let connection = Connection::open(d.path().join("db")).unwrap();
|
||||
let ticket_refs = connection
|
||||
.prepare("SELECT event_ref FROM typed_ticket_events WHERE event_index >= 0")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ticket_refs,
|
||||
vec!["T:0"],
|
||||
"replay must not append another post"
|
||||
);
|
||||
let (state, active): (String, bool) = connection.query_row(
|
||||
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
||||
[],
|
||||
@@ -477,6 +489,87 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
|
||||
assert!(!json.contains(banned), "{banned} in {json}")
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn integration_post_failure_rolls_back_and_same_operation_recovers_with_legacy_empty_ref() {
|
||||
let (dir, store) = fixture();
|
||||
open(&store);
|
||||
let review = approve(&store, "source", "approved");
|
||||
request(&store, "source", "pending");
|
||||
let completion = CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "recover-integration".into(),
|
||||
approval_event_id: review.event_id,
|
||||
current_subject_ref: "source".into(),
|
||||
target_ref_before: "before".into(),
|
||||
target_ref_after: "after".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(5),
|
||||
};
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
// Retain a comment written before integration populated event_ref.
|
||||
connection.execute_batch(
|
||||
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref)
|
||||
VALUES('W','T',0,'comment','Legacy integration','');
|
||||
CREATE TRIGGER reject_integration_post BEFORE INSERT ON typed_ticket_events
|
||||
WHEN NEW.heading='Merge Request integrated'
|
||||
BEGIN SELECT RAISE(ABORT,'injected integration post failure'); END;",
|
||||
).unwrap();
|
||||
let before = store.get_by_id("W", "MR").unwrap();
|
||||
let error = store.complete(completion.clone()).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("injected integration post failure")
|
||||
);
|
||||
let failed = store.get_by_id("W", "MR").unwrap();
|
||||
assert_eq!(failed.state, MergeRequestState::Open);
|
||||
assert_eq!(failed.thread, before.thread);
|
||||
assert!(store.authorize_review_submission("MR", "pending").is_ok());
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM typed_ticket_event_attributes",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
|
||||
connection
|
||||
.execute_batch("DROP TRIGGER reject_integration_post")
|
||||
.unwrap();
|
||||
let merged = store.complete(completion.clone()).unwrap();
|
||||
assert_eq!(store.complete(completion).unwrap(), merged);
|
||||
assert_eq!(
|
||||
store.get_by_id("W", "MR").unwrap().state,
|
||||
MergeRequestState::Merged
|
||||
);
|
||||
assert!(store.authorize_review_submission("MR", "pending").is_err());
|
||||
let posts = connection
|
||||
.prepare("SELECT event_ref,body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY event_index")
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
posts.len(),
|
||||
2,
|
||||
"retry and replay must append exactly one post"
|
||||
);
|
||||
assert_eq!(posts[0], (String::new(), "Legacy integration".into()));
|
||||
assert_eq!(posts[1].0, "T:1");
|
||||
assert!(posts[1].1.contains("`MR`"));
|
||||
assert_eq!(connection.query_row(
|
||||
"SELECT value FROM typed_ticket_event_attributes WHERE event_index=1 AND key='operation_id'",
|
||||
[], |row| row.get::<_, String>(0),
|
||||
).unwrap(), "recover-integration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_returns() {
|
||||
let (_d, s) = fixture();
|
||||
@@ -955,7 +1048,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL,'T:1')",
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
@@ -1082,7 +1175,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL,'T:1')",
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
@@ -1307,6 +1400,24 @@ fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
|
||||
assert_eq!(second_request.merged_result().unwrap(), &second_merge);
|
||||
assert_eq!(first_request.integration_approval().unwrap(), &first);
|
||||
assert_eq!(second_request.integration_approval().unwrap(), &second);
|
||||
let posts = connection
|
||||
.prepare(
|
||||
"SELECT event.event_ref, attribute.value
|
||||
FROM typed_ticket_events event
|
||||
JOIN typed_ticket_event_attributes attribute USING(workspace_id,ticket_id,event_index)
|
||||
WHERE attribute.key='merge_request_id' ORDER BY event.event_index",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
posts,
|
||||
vec![("T:0".into(), "MR".into()), ("T:1".into(), "MR-2".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1324,7 +1435,7 @@ fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migrat
|
||||
}"#;
|
||||
connection.execute_batch(
|
||||
"UPDATE typed_tickets SET workflow_state='done' WHERE workspace_id='W' AND ticket_id='T';
|
||||
INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body');"
|
||||
INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body','T:0');"
|
||||
).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
|
||||
@@ -225,6 +225,10 @@ impl_openapi_schema!(
|
||||
PasskeyLoginOptionsResponse,
|
||||
PasskeyLoginCompleteRequest,
|
||||
PasskeyAdditionOptionsRequest,
|
||||
PasskeySummary,
|
||||
PasskeyListResponse,
|
||||
PasskeyAdditionResultRequest,
|
||||
PasskeyAdditionResultResponse,
|
||||
PasskeyRegistrationOptionsRequest,
|
||||
PasskeyRegistrationOptionsResponse,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
@@ -648,6 +652,33 @@ pub trait ServerApi {
|
||||
#[body] request: PasskeyRegistrationCompleteRequest,
|
||||
) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>;
|
||||
|
||||
#[get(
|
||||
"/api/auth/passkeys",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [403, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn auth_passkey_list(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
) -> Result<PasskeyListResponse, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [403, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn auth_passkey_addition_result(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[body] request: PasskeyAdditionResultRequest,
|
||||
) -> Result<PasskeyAdditionResultResponse, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/auth/passkeys/addition/options",
|
||||
status = 200,
|
||||
@@ -4115,6 +4146,8 @@ impl RequestActor {
|
||||
pub struct WhoamiResponse {
|
||||
pub actor: Option<RequestActor>,
|
||||
pub session: BrowserSessionState,
|
||||
/// Non-secret comparison evidence, never an authentication credential.
|
||||
pub authentication_reference: Option<String>,
|
||||
}
|
||||
|
||||
/// Browser navigation evidence, not an authorization grant or a generic 401 classification.
|
||||
@@ -4142,6 +4175,8 @@ pub struct AuthBootstrapUserRequest {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AuthUserResponse {
|
||||
pub user: AuthenticatedUser,
|
||||
/// Comparison evidence for the exact newly issued session, not a credential.
|
||||
pub authentication_reference: String,
|
||||
}
|
||||
|
||||
/// Starts new-account registration only; an existing handle is never interpreted as key addition.
|
||||
@@ -4158,16 +4193,64 @@ pub struct PasskeyRegistrationOptionsRequest {
|
||||
pub browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
/// Adds a credential to the current authenticated actor; target identity is never public input.
|
||||
/// Adds to the current actor. Expected identity/binding fields only fence stale UI;
|
||||
/// they never select an owner or authorize an addition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PasskeyAdditionOptionsRequest {
|
||||
pub expected_user_id: String,
|
||||
pub expected_account_id: String,
|
||||
pub expected_authentication_reference: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
/// Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed.
|
||||
/// Normal-login verification is observed verifier success, not inferred discoverability.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PasskeySummary {
|
||||
pub key_reference: String,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub normal_login_verified_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PasskeyListResponse {
|
||||
pub user: AuthenticatedUser,
|
||||
pub keys: Vec<PasskeySummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PasskeyAdditionResultRequest {
|
||||
pub challenge_id: String,
|
||||
pub expected_user_id: String,
|
||||
pub expected_account_id: String,
|
||||
/// Original options authentication reference; the current same-owner session may differ.
|
||||
pub expected_authentication_reference: String,
|
||||
}
|
||||
|
||||
/// Unknown is not proof that registration failed: completion may still be in flight.
|
||||
/// Saved proves the credential commit, not session issuance or current session health.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PasskeyAdditionResultResponse {
|
||||
Unknown {},
|
||||
Saved {
|
||||
user: AuthenticatedUser,
|
||||
key: PasskeySummary,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -11642,6 +11725,10 @@ pub fn auth_api_typescript() -> String {
|
||||
AuthBootstrapUserRequest::decl(&config),
|
||||
AuthUserResponse::decl(&config),
|
||||
PasskeyAdditionOptionsRequest::decl(&config),
|
||||
PasskeySummary::decl(&config),
|
||||
PasskeyListResponse::decl(&config),
|
||||
PasskeyAdditionResultRequest::decl(&config),
|
||||
PasskeyAdditionResultResponse::decl(&config),
|
||||
PasskeyRegistrationOptionsRequest::decl(&config),
|
||||
PasskeyRegistrationOptionsResponse::decl(&config),
|
||||
PasskeyRegistrationCompleteRequest::decl(&config),
|
||||
@@ -14069,7 +14156,8 @@ mod tests {
|
||||
"display_name": "Hare",
|
||||
"auth_method": "browser_session"
|
||||
},
|
||||
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" }
|
||||
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" },
|
||||
"authentication_reference": "synthetic-session-reference"
|
||||
});
|
||||
let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone())
|
||||
.expect("server whoami fixture should match shared DTO");
|
||||
@@ -14091,7 +14179,8 @@ mod tests {
|
||||
"account_id": "account-1",
|
||||
"handle": "hare",
|
||||
"display_name": "Hare"
|
||||
}
|
||||
},
|
||||
"authentication_reference": "synthetic-session-reference"
|
||||
});
|
||||
let decoded = serde_json::from_value::<AuthUserResponse>(auth_user.clone())
|
||||
.expect("server auth-user fixture should match shared DTO");
|
||||
@@ -14264,7 +14353,7 @@ mod tests {
|
||||
serde_json::json!({"kind":"expired"}),
|
||||
serde_json::json!({"kind":"authenticated", "expires_at":null}),
|
||||
] {
|
||||
let value = serde_json::json!({"actor":null, "session":session});
|
||||
let value = serde_json::json!({"actor":null, "session":session, "authentication_reference": null});
|
||||
let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), value);
|
||||
}
|
||||
@@ -14802,10 +14891,28 @@ mod openapi_artifact_tests {
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_ok()
|
||||
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passkey_addition_requires_expected_owner_and_binding() {
|
||||
let expected = serde_json::json!({
|
||||
"expected_user_id": "user-a", "expected_account_id": "account-a",
|
||||
"expected_authentication_reference": "reference"
|
||||
});
|
||||
assert!(serde_json::from_value::<PasskeyAdditionOptionsRequest>(expected.clone()).is_ok());
|
||||
for field in [
|
||||
"expected_user_id",
|
||||
"expected_account_id",
|
||||
"expected_authentication_reference",
|
||||
] {
|
||||
let mut missing = expected.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
assert!(serde_json::from_value::<PasskeyAdditionOptionsRequest>(missing).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
|
||||
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
|
||||
|
||||
@@ -19,6 +19,8 @@ required-features = ["ws-server", "fs-store"]
|
||||
[features]
|
||||
default = ["ws-server", "fs-store"]
|
||||
fs-store = ["dep:fs4"]
|
||||
# Offline fixture tooling only; never enabled by the product defaults.
|
||||
test-support = ["fs-store", "ws-server"]
|
||||
http-server = ["dep:axum", "dep:tower", "dep:reqwest"]
|
||||
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
|
||||
|
||||
|
||||
@@ -8,3 +8,34 @@ Manages Worker creation, stopping, and Restore, execution connections within the
|
||||
- [`execution`] — execution backends and operation-specific results.
|
||||
- [`working_directory`] — repository materialization, session resources, and safe deletion.
|
||||
- [`workspace_issuer`] — Workspace signature verification and replay prevention.
|
||||
|
||||
## Offline stopped-Worker fixtures
|
||||
|
||||
The nondefault `test-support` feature exposes `test_support::StoppedWorkerTemplate`.
|
||||
It is intentionally limited to current-schema, stopped, attachment-free artificial
|
||||
conversations with one Segment, text history, and no pending execution/activation.
|
||||
It does not add a product endpoint or change create/restore authorization.
|
||||
|
||||
1. With the source Runtime offline, call `StoppedWorkerTemplate::extract(worker_dir)`.
|
||||
The source must have its existing Runtime owner lock and identity. Extraction is
|
||||
read-only; the returned serde template excludes source auth, saved Manifest,
|
||||
request/config references, run directories, and Runtime operation history.
|
||||
2. Initialize **new** Runtime identity and Workspace trust in a fresh destination
|
||||
using normal setup. Preserve the template's Runtime and Workspace IDs.
|
||||
3. Call `template.install(&options, request, &resolved_manifest)` before starting
|
||||
the Runtime. Supply a fresh typed `CreateWorkerRequest` (including config/profile
|
||||
references and trusted memory settings) and `manifest::WorkerManifest` resolved
|
||||
for that environment. Its Worker name must equal `template.worker_name()`.
|
||||
4. Start normally and use the ordinary explicit restore operation. Installation
|
||||
itself creates no execution and leaves the Worker stopped with a fresh guard.
|
||||
|
||||
The template preserves the original Session manifest/JSONL bytes, including stable
|
||||
entry IDs, timestamps, and settled submission receipts. It rejects resumable queues
|
||||
and Feature invocation history. A saved system prompt is required for restore;
|
||||
checkpoint receipts must match the Worker's typed receipt schema. Preserved records
|
||||
must use canonical writer field shapes: unknown/duplicate fields (including nested
|
||||
fields), discarded optional fields, and opaque Feature history payloads are rejected,
|
||||
not stripped or rewritten. Portability/secret-marker checks do not replace
|
||||
review of the supplied artificial conversation or its independently verified hashes.
|
||||
The installer refuses any destination content other than initialized `auth.toml`;
|
||||
interrupted publication is fail-closed, not an overwrite/recovery facility.
|
||||
|
||||
@@ -39,6 +39,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
#[path = "test_support.rs"]
|
||||
pub mod test_support;
|
||||
|
||||
const SCHEMA_VERSION: u32 = 12;
|
||||
// Frozen source schemas: do not move their meaning when adding a migration.
|
||||
const SOURCE_SCHEMA_VERSION: u32 = 11;
|
||||
|
||||
@@ -3206,9 +3206,13 @@ mod tests {
|
||||
.await
|
||||
.expect_err("close must be terminal");
|
||||
assert!(matches!(
|
||||
closed_error,
|
||||
closed_error.classification_source(),
|
||||
workdir::WorkdirError::Unavailable(_)
|
||||
));
|
||||
assert_eq!(
|
||||
closed_error.failure_reason(),
|
||||
Some(workdir::WorkdirFailureReason::SessionClosed)
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.workdir_sessions
|
||||
|
||||
@@ -32,6 +32,8 @@ pub mod workspace_issuer;
|
||||
#[cfg(feature = "http-server")]
|
||||
pub mod workspace_request;
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use fs_store::test_support;
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||
pub use management::RuntimeOptions;
|
||||
|
||||
@@ -0,0 +1,859 @@
|
||||
//! Offline support for the approved, attachment-free stopped Worker fixture.
|
||||
//!
|
||||
//! Enabled only by `test-support`. This is not an import API: extraction accepts a
|
||||
//! current-schema, settled, single-Segment aggregate; installation accepts only a
|
||||
//! fresh Runtime directory containing its newly initialized `auth.toml`. No
|
||||
//! Runtime or execution backend is constructed. Original Session bytes are never
|
||||
//! rewritten. The caller must separately review/attest the artificial conversation;
|
||||
//! structural checks and secret-marker checks cannot prove arbitrary prose safe.
|
||||
|
||||
use super::*;
|
||||
use crate::catalog::ProfileSourceArchiveSource;
|
||||
use crate::workspace_issuer::{
|
||||
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, validate_workspace_issuer_trust_records,
|
||||
};
|
||||
use session_store::{
|
||||
SegmentId, SessionId, WorkerActiveSegmentRef, WorkerMetadata, WorkerSessionStore,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "test_support_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
const TEMPLATE_VERSION: u32 = 1;
|
||||
const MAX_SESSION_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Portable fixture, intentionally without Debug (Session material is private).
|
||||
/// Fields are private and every deserialized template is validated on install.
|
||||
/// Serialize this value to retain the fixture; do not serialize its source aggregate.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StoppedWorkerTemplate {
|
||||
template_version: u32,
|
||||
runtime_schema_version: u32,
|
||||
runtime_id: String,
|
||||
workspace_id: String,
|
||||
worker_id: WorkerId,
|
||||
created_at_ms: Option<u64>,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
session_manifest: Vec<u8>,
|
||||
segment_jsonl: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SessionManifest {
|
||||
schema_version: u32,
|
||||
session_id: SessionId,
|
||||
}
|
||||
|
||||
// Read-only projection: never retain source key material in a template.
|
||||
#[derive(Deserialize)]
|
||||
struct AuthIdentity {
|
||||
identity_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SourceAuth {
|
||||
identity: AuthIdentity,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct InitializedAuth {
|
||||
identity: crate::auth::RuntimeIdentityMaterial,
|
||||
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
|
||||
#[serde(default)]
|
||||
archived_legacy_issuers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GuardState {
|
||||
active_request_id: Option<String>,
|
||||
owners: BTreeMap<String, GuardOwner>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GuardOwner {
|
||||
receipt: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
fn reject(message: &'static str) -> RuntimeError {
|
||||
RuntimeError::InvalidRequest(format!("stopped Worker fixture: {message}"))
|
||||
}
|
||||
|
||||
fn io<T>(result: std::io::Result<T>) -> Result<T, RuntimeError> {
|
||||
result.map_err(|_| reject("filesystem operation failed"))
|
||||
}
|
||||
|
||||
fn decode<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, RuntimeError> {
|
||||
serde_json::from_slice(bytes).map_err(|_| reject("invalid typed document"))
|
||||
}
|
||||
|
||||
fn no_symlink_components(path: &Path) -> Result<(), RuntimeError> {
|
||||
let mut current = PathBuf::new();
|
||||
for part in path.components() {
|
||||
if matches!(part, std::path::Component::ParentDir) {
|
||||
return Err(reject("parent traversal is unsupported"));
|
||||
}
|
||||
current.push(part);
|
||||
let metadata = io(fs::symlink_metadata(¤t))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(reject("symlinks are unsupported"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_tree(path: &Path, budget: &mut usize) -> Result<(), RuntimeError> {
|
||||
if *budget == 0 {
|
||||
return Err(reject("file count exceeds fixture limit"));
|
||||
}
|
||||
*budget -= 1;
|
||||
let metadata = io(fs::symlink_metadata(path))?;
|
||||
if metadata.is_dir() {
|
||||
for entry in io(fs::read_dir(path))? {
|
||||
check_tree(&io(entry)?.path(), budget)?;
|
||||
}
|
||||
} else if !metadata.is_file() {
|
||||
return Err(reject("symlinks and special files are unsupported"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn regular_file(path: &Path) -> Result<(), RuntimeError> {
|
||||
no_symlink_components(path)?;
|
||||
if !io(fs::symlink_metadata(path))?.is_file() {
|
||||
return Err(reject("expected a regular file"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bytes(path: &Path) -> Result<Vec<u8>, RuntimeError> {
|
||||
regular_file(path)?;
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOATIME | libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = io(options.open(path))?;
|
||||
let mut result = Vec::new();
|
||||
io(file
|
||||
.take(MAX_SESSION_BYTES as u64 + 1)
|
||||
.read_to_end(&mut result))?;
|
||||
if result.len() > MAX_SESSION_BYTES {
|
||||
return Err(reject("file exceeds fixture limit"));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn text(path: &Path) -> Result<String, RuntimeError> {
|
||||
String::from_utf8(bytes(path)?).map_err(|_| reject("invalid UTF-8 document"))
|
||||
}
|
||||
|
||||
fn portable_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 256
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"-_".contains(&c))
|
||||
}
|
||||
|
||||
impl StoppedWorkerTemplate {
|
||||
pub fn runtime_id(&self) -> &str {
|
||||
&self.runtime_id
|
||||
}
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
&self.workspace_id
|
||||
}
|
||||
pub fn worker_id(&self) -> WorkerId {
|
||||
self.worker_id
|
||||
}
|
||||
pub fn worker_name(&self) -> String {
|
||||
runtime_worker_name(self.worker_id)
|
||||
}
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.session_id
|
||||
}
|
||||
pub fn segment_id(&self) -> SegmentId {
|
||||
self.segment_id
|
||||
}
|
||||
|
||||
/// Extract without migration, rewriting metadata, or copying credentials,
|
||||
/// resolved manifests, execution-operation receipts, or run directories.
|
||||
/// The source Runtime must be offline; its existing owner lock is held for
|
||||
/// the entire read. The path must be `<runtime>/workers/<WorkerId>`.
|
||||
pub fn extract(worker_dir: &Path) -> Result<Self, RuntimeError> {
|
||||
no_symlink_components(worker_dir)?;
|
||||
let workers = worker_dir
|
||||
.parent()
|
||||
.ok_or_else(|| reject("invalid aggregate path"))?;
|
||||
if workers.file_name().and_then(|s| s.to_str()) != Some(WORKERS_DIR) {
|
||||
return Err(reject("invalid aggregate path"));
|
||||
}
|
||||
let root = workers
|
||||
.parent()
|
||||
.ok_or_else(|| reject("invalid Runtime path"))?;
|
||||
// Require the existing lock, rather than creating a file in the source.
|
||||
let lock_path = root.with_file_name(format!(
|
||||
".{}.runtime-owner.lock",
|
||||
root.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| reject("invalid Runtime path"))?
|
||||
));
|
||||
regular_file(&lock_path)?;
|
||||
let lock = io(OpenOptions::new().read(true).open(lock_path))?;
|
||||
if !io(FileExt::try_lock_shared(&lock))? {
|
||||
return Err(reject("source Runtime is not offline"));
|
||||
}
|
||||
let _lock = RuntimeStoreOwnerLock { file: lock };
|
||||
check_tree(worker_dir, &mut 256)?;
|
||||
let snapshot: RuntimeSnapshot = decode(&bytes(&root.join(RUNTIME_FILE))?)?;
|
||||
snapshot
|
||||
.validate(&root.join(RUNTIME_FILE))
|
||||
.map_err(|_| reject("unsupported Runtime schema"))?;
|
||||
if !snapshot.subject_create_recovery_fences.is_empty()
|
||||
|| !snapshot.subject_releases.is_empty()
|
||||
{
|
||||
return Err(reject("source has lifecycle operations"));
|
||||
}
|
||||
let record: WorkerAggregateRecord = decode(&bytes(&worker_dir.join(WORKER_FILE))?)?;
|
||||
let record = record
|
||||
.validate(&worker_dir.join(WORKER_FILE))
|
||||
.map_err(|_| reject("invalid Worker schema or identity"))?;
|
||||
let guard: GuardState = decode(
|
||||
&serde_json::to_vec(&record.restore_guard)
|
||||
.map_err(|_| reject("invalid restore guard"))?,
|
||||
)?;
|
||||
if record.status != WorkerStatus::Stopped
|
||||
|| guard.active_request_id.is_some()
|
||||
|| guard.owners.values().any(|owner| owner.receipt.is_none())
|
||||
{
|
||||
return Err(reject("Worker is not settled and stopped"));
|
||||
}
|
||||
let PersistedWorkerExecutionState::Available(execution) = &record.execution_state else {
|
||||
return Err(reject("pending or unavailable execution metadata"));
|
||||
};
|
||||
// A stopped aggregate may retain the empty historical binding marker.
|
||||
// It is deliberately not copied to the installed Worker.
|
||||
if execution.restore_intent != WorkerRestoreIntent::Explicit {
|
||||
return Err(reject("Worker requires explicit restore"));
|
||||
}
|
||||
if !record.workdir_attachments.is_empty() || !record.logical_workdir_attachments.is_empty()
|
||||
{
|
||||
return Err(reject("attachments are unsupported"));
|
||||
}
|
||||
check_request(&execution.request, false)?;
|
||||
let workspace_id = record
|
||||
.workspace_id
|
||||
.ok_or_else(|| reject("missing Workspace identity"))?;
|
||||
let metadata: WorkerMetadata = decode(&bytes(&worker_dir.join(WORKER_METADATA_FILE))?)?;
|
||||
crate::worker_backend::validate_fixture_restore_metadata(&metadata, &execution.request)
|
||||
.map_err(|_| reject("invalid source restore Manifest or settings"))?;
|
||||
if metadata.worker_name != runtime_worker_name(record.worker_id)
|
||||
|| metadata.workspace_id.as_deref() != Some(&workspace_id)
|
||||
|| !metadata.spawned_children.is_empty()
|
||||
|| !metadata.reclaimed_children.is_empty()
|
||||
|| !metadata.peers.is_empty()
|
||||
|| metadata.subjektiv_session_attribution.is_some()
|
||||
{
|
||||
return Err(reject("unsupported or inconsistent Worker metadata"));
|
||||
}
|
||||
let active = metadata
|
||||
.active
|
||||
.ok_or_else(|| reject("missing active Session"))?;
|
||||
let segment_id = active
|
||||
.segment_id
|
||||
.ok_or_else(|| reject("unmaterialized Session"))?;
|
||||
let source_auth: SourceAuth = toml::from_str(&text(&root.join("auth.toml"))?)
|
||||
.map_err(|_| reject("missing Runtime identity"))?;
|
||||
let session = worker_dir.join("session");
|
||||
let fixture = Self {
|
||||
template_version: TEMPLATE_VERSION,
|
||||
runtime_schema_version: SCHEMA_VERSION,
|
||||
runtime_id: source_auth.identity.identity_id,
|
||||
workspace_id,
|
||||
worker_id: record.worker_id,
|
||||
created_at_ms: record.created_at_ms,
|
||||
session_id: active.session_id,
|
||||
segment_id,
|
||||
session_manifest: bytes(&session.join("session.json"))?,
|
||||
segment_jsonl: bytes(&session.join("segments").join(format!("{segment_id}.jsonl")))?,
|
||||
};
|
||||
fixture.validate_shape()?;
|
||||
fixture.validate_session(&session)?;
|
||||
Ok(fixture)
|
||||
}
|
||||
|
||||
/// Install after normal identity/trust initialization, before Runtime start.
|
||||
/// `request` and `manifest` supply NEW backend/config/profile references and
|
||||
/// freshly resolved policy, not a launch. The saved source Manifest is never
|
||||
/// reused. `manifest.worker.name` must equal `worker_name()`.
|
||||
/// Only `auth.toml` may already exist. The owner lock fences live Runtimes;
|
||||
/// staged files are synced and published without overwriting any Worker.
|
||||
/// An interrupted publication fails closed as an incomplete Runtime layout.
|
||||
pub fn install(
|
||||
&self,
|
||||
options: &FsRuntimeStoreOptions,
|
||||
request: CreateWorkerRequest,
|
||||
manifest: &manifest::WorkerManifest,
|
||||
) -> Result<(), RuntimeError> {
|
||||
self.validate_shape()?;
|
||||
check_request(&request, true)?;
|
||||
if manifest.worker.name != self.worker_name() {
|
||||
return Err(reject("resolved Manifest Worker name mismatch"));
|
||||
}
|
||||
let mut metadata = WorkerMetadata::new(
|
||||
self.worker_name(),
|
||||
Some(WorkerActiveSegmentRef {
|
||||
session_id: self.session_id,
|
||||
segment_id: Some(self.segment_id),
|
||||
}),
|
||||
)
|
||||
.with_workspace_id(&self.workspace_id);
|
||||
metadata.resolved_manifest_snapshot = Some(
|
||||
manifest::write_persisted_worker_manifest_snapshot(manifest)
|
||||
.map_err(|_| reject("invalid resolved Manifest"))?,
|
||||
);
|
||||
crate::worker_backend::validate_fixture_restore_metadata(&metadata, &request)
|
||||
.map_err(|_| reject("resolved Manifest does not match fresh request settings"))?;
|
||||
if options.runtime_id != self.runtime_id
|
||||
|| request.worker_id != self.worker_id
|
||||
|| request
|
||||
.workspace_api
|
||||
.as_ref()
|
||||
.map(|api| api.workspace_id.as_str())
|
||||
!= Some(&self.workspace_id)
|
||||
{
|
||||
return Err(reject("installation identity mismatch"));
|
||||
}
|
||||
no_symlink_components(&options.root)?;
|
||||
check_tree(&options.root, &mut 256)?;
|
||||
// Validate the lock location before the ordinary owner-lock opener.
|
||||
let lock_path = options.root.with_file_name(format!(
|
||||
".{}.runtime-owner.lock",
|
||||
options
|
||||
.root
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| reject("invalid Runtime path"))?
|
||||
));
|
||||
if fs::symlink_metadata(&lock_path).is_ok() {
|
||||
regular_file(&lock_path)?;
|
||||
}
|
||||
let lock = acquire_runtime_store_owner_lock(&options.root)?;
|
||||
validate_fresh_runtime_store_layout(&options.root)
|
||||
.map_err(|_| reject("destination is not fresh; refusing overwrite"))?;
|
||||
let auth: InitializedAuth = toml::from_str(&text(&options.root.join("auth.toml"))?)
|
||||
.map_err(|_| reject("initialize Runtime identity and trust before install"))?;
|
||||
if auth.identity.identity_id != self.runtime_id
|
||||
|| !auth.archived_legacy_issuers.is_empty()
|
||||
|| auth.identity.signing_key().is_err()
|
||||
{
|
||||
return Err(reject("invalid destination Runtime identity"));
|
||||
}
|
||||
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
|
||||
.map_err(|_| reject("invalid destination Workspace trust"))?;
|
||||
let api = request
|
||||
.workspace_api
|
||||
.as_ref()
|
||||
.expect("validated Workspace binding");
|
||||
if !auth.workspace_issuers.iter().any(|trust| {
|
||||
trust.workspace_id == self.workspace_id
|
||||
&& trust.backend_url == api.base_url
|
||||
&& trust.state == WorkspaceIssuerTrustState::Active
|
||||
}) {
|
||||
return Err(reject("request does not match initialized Workspace trust"));
|
||||
}
|
||||
let stage = options
|
||||
.root
|
||||
.join(format!(".fixture-{}", uuid::Uuid::now_v7()));
|
||||
io(fs::create_dir(&stage))?;
|
||||
let store = FsRuntimeStore {
|
||||
root: stage.clone(),
|
||||
_owner_lock: Some(lock),
|
||||
worker_write_fault: Arc::new(AtomicU8::new(AtomicWriteFault::None as u8)),
|
||||
};
|
||||
let result = (|| {
|
||||
let worker = PersistedWorkerRecord {
|
||||
restore_guard: crate::runtime::RestoreGuard::default(),
|
||||
worker_ref: WorkerRef::new(self.worker_id),
|
||||
worker_id: self.worker_id,
|
||||
profile: request.profile.clone(),
|
||||
display_name: request.display_name.clone(),
|
||||
profile_source: request.profile_source.reference(),
|
||||
config_bundle: request.config_bundle.clone(),
|
||||
created_at_ms: self.created_at_ms,
|
||||
status: WorkerStatus::Stopped,
|
||||
last_finished_submission_request_id: None,
|
||||
workspace_id: Some(self.workspace_id.clone()),
|
||||
workdir_attachments: Vec::new(),
|
||||
logical_workdir_attachments: Vec::new(),
|
||||
execution_state: PersistedWorkerExecutionState::Available(
|
||||
PersistedWorkerExecution {
|
||||
request,
|
||||
binding: None,
|
||||
restore_intent: WorkerRestoreIntent::Explicit,
|
||||
},
|
||||
),
|
||||
};
|
||||
WorkerAggregateRecord::from_persisted(&worker)
|
||||
.validate(&store.worker_dir(&self.worker_id).join(WORKER_FILE))
|
||||
.map_err(|_| reject("invalid installation request"))?;
|
||||
store.write_worker_record(&worker)?;
|
||||
let worker_dir = store.worker_dir(&self.worker_id);
|
||||
atomic_write_json(
|
||||
&worker_dir.join(WORKER_METADATA_FILE),
|
||||
&metadata,
|
||||
"write fixture metadata",
|
||||
)?;
|
||||
let session = worker_dir.join("session");
|
||||
io(fs::create_dir_all(session.join("segments")))?;
|
||||
write_new(&session.join("session.json"), &self.session_manifest)?;
|
||||
write_new(
|
||||
&session
|
||||
.join("segments")
|
||||
.join(format!("{}.jsonl", self.segment_id)),
|
||||
&self.segment_jsonl,
|
||||
)?;
|
||||
self.validate_session(&session)?;
|
||||
store.write_runtime_snapshot(&PersistedRuntimeState {
|
||||
subject_create_recovery_fences: BTreeMap::new(),
|
||||
subject_releases: Vec::new(),
|
||||
// Match normal store initialization: this is catalog availability,
|
||||
// not a process or Worker execution start.
|
||||
display_name: options.display_name.clone(),
|
||||
status: RuntimeStatus::Running,
|
||||
next_diagnostic_id: 1,
|
||||
workers: BTreeMap::new(),
|
||||
workspace_owners: BTreeMap::new(),
|
||||
diagnostics: Vec::new(),
|
||||
})?;
|
||||
let loaded = store.load_runtime_state()?;
|
||||
if loaded.workers.len() != 1 || !loaded.diagnostics.is_empty() {
|
||||
return Err(reject("staged aggregate failed ordinary store roundtrip"));
|
||||
}
|
||||
sync_runtime_tree(&stage)?;
|
||||
// No destination existed when checked under the owner lock. Hard-link
|
||||
// the snapshot last with create-new semantics; never replace a file.
|
||||
// Reserve the destination catalog with create-new semantics. Even
|
||||
// an empty preexisting workers directory must never be replaced.
|
||||
let destination_workers = options.root.join(WORKERS_DIR);
|
||||
io(fs::create_dir(&destination_workers))?;
|
||||
io(fs::rename(
|
||||
store.worker_dir(&self.worker_id),
|
||||
destination_workers.join(self.worker_id.to_string()),
|
||||
))?;
|
||||
sync_directory(&destination_workers, "publish stopped Worker aggregate")?;
|
||||
io(fs::hard_link(
|
||||
stage.join(RUNTIME_FILE),
|
||||
options.root.join(RUNTIME_FILE),
|
||||
))?;
|
||||
sync_directory(&options.root, "publish stopped fixture")?;
|
||||
Ok(())
|
||||
})();
|
||||
// Only remove our private staging directory, never existing destination data.
|
||||
let cleanup = fs::remove_dir_all(&stage);
|
||||
result?;
|
||||
io(cleanup)?;
|
||||
sync_directory(&options.root, "finish stopped fixture")
|
||||
}
|
||||
|
||||
fn validate_shape(&self) -> Result<(), RuntimeError> {
|
||||
if self.template_version != TEMPLATE_VERSION
|
||||
|| self.runtime_schema_version != SCHEMA_VERSION
|
||||
{
|
||||
return Err(reject("unsupported template schema"));
|
||||
}
|
||||
if !portable_id(&self.runtime_id)
|
||||
|| !portable_id(&self.workspace_id)
|
||||
|| self.session_id.get_version_num() != 7
|
||||
|| self.segment_id.get_version_num() != 7
|
||||
|| self.session_manifest.len() > MAX_SESSION_BYTES
|
||||
|| self.segment_jsonl.len() > MAX_SESSION_BYTES
|
||||
{
|
||||
return Err(reject("invalid portable identity or size"));
|
||||
}
|
||||
let manifest: SessionManifest = decode(&self.session_manifest)?;
|
||||
if manifest.session_id != self.session_id
|
||||
|| manifest.schema_version == 0
|
||||
|| !self.segment_jsonl.ends_with(b"\n")
|
||||
{
|
||||
return Err(reject("Session identity or committed boundary mismatch"));
|
||||
}
|
||||
// Read-only JSON traversal, never mutation/reconstruction of Session bytes.
|
||||
// Reject known secret/host markers even in otherwise valid typed records.
|
||||
for line in self
|
||||
.segment_jsonl
|
||||
.split(|b| *b == b'\n')
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
let _: PortableJson = decode(line)?;
|
||||
validate_preserved_record(line)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_history_entry(
|
||||
&self,
|
||||
entry: &session_store::LoggedHistoryEntry,
|
||||
ids: &mut std::collections::BTreeSet<String>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
if !portable_id(&entry.metadata.entry_id.0)
|
||||
|| !ids.insert(entry.metadata.entry_id.0.clone())
|
||||
{
|
||||
return Err(reject("duplicate or invalid entry identity"));
|
||||
}
|
||||
if entry
|
||||
.metadata
|
||||
.input_metadata
|
||||
.as_ref()
|
||||
.is_some_and(|metadata| {
|
||||
metadata.feature_payload().is_some() || metadata.public_item().is_some()
|
||||
})
|
||||
{
|
||||
// These are opaque owner-specific payloads, not part of the narrow
|
||||
// artificial conversation contract. Never preserve unchecked JSON.
|
||||
return Err(reject("Feature history payloads are unsupported"));
|
||||
}
|
||||
if !matches!(
|
||||
entry.item,
|
||||
session_store::LoggedItem::Message { .. }
|
||||
| session_store::LoggedItem::AssistantResponseBoundary { .. }
|
||||
) {
|
||||
return Err(reject("only text conversation records are supported"));
|
||||
}
|
||||
if let session_store::LoggedSessionHistoryOrigin::ModelOutput { worker } =
|
||||
&entry.metadata.origin
|
||||
{
|
||||
// Session producer IDs identify the original execution, not the
|
||||
// Workspace catalog Worker. Preserve them; do not rebind/rewrite them.
|
||||
if !portable_id(&worker.worker_id)
|
||||
|| worker
|
||||
.workspace_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| id != &self.workspace_id)
|
||||
|| worker
|
||||
.runtime_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| id != &self.runtime_id)
|
||||
{
|
||||
return Err(reject("Session origin identity mismatch"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_session(&self, root: &Path) -> Result<(), RuntimeError> {
|
||||
for entry in io(fs::read_dir(root))? {
|
||||
let entry = io(entry)?;
|
||||
match entry.file_name().to_str() {
|
||||
Some("session.json" | "segments") => {}
|
||||
Some("artifacts") => check_empty_artifacts(&entry.path())?,
|
||||
_ => return Err(reject("unsupported Session files")),
|
||||
}
|
||||
}
|
||||
let entries = io(fs::read_dir(root.join("segments")))?.collect::<Result<Vec<_>, _>>();
|
||||
let entries = io(entries)?;
|
||||
if entries.len() != 1
|
||||
|| entries[0].file_name() != format!("{}.jsonl", self.segment_id).as_str()
|
||||
{
|
||||
return Err(reject("only the fixed single-Segment fixture is supported"));
|
||||
}
|
||||
let store = WorkerSessionStore::open_read_only(root)
|
||||
.map_err(|_| reject("unsupported Session schema"))?;
|
||||
let logs = store
|
||||
.read_all_read_only_bounded(self.session_id, self.segment_id, MAX_SESSION_BYTES as u64)
|
||||
.map_err(|_| reject("invalid canonical Session records"))?
|
||||
.0;
|
||||
if !matches!(logs.first(), Some(session_store::LogEntry::AnnotatedSegmentStart {
|
||||
session_id, system_prompt: Some(_), forked_from: None, compacted_from: None, ..
|
||||
}) if *session_id == self.session_id)
|
||||
{
|
||||
return Err(reject(
|
||||
"missing system prompt, invalid Segment seed, or unsupported lineage",
|
||||
));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for (index, log) in logs.iter().enumerate() {
|
||||
use session_store::LogEntry;
|
||||
match log {
|
||||
LogEntry::AnnotatedSegmentStart { history, .. }
|
||||
if index == 0 && history.is_empty() => {}
|
||||
LogEntry::AnnotatedUserInput {
|
||||
history,
|
||||
extensions,
|
||||
..
|
||||
} => {
|
||||
for extension in extensions {
|
||||
validate_settled_activation(extension)?;
|
||||
}
|
||||
for entry in history {
|
||||
self.validate_history_entry(entry, &mut ids)?;
|
||||
}
|
||||
}
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. } => {
|
||||
self.validate_history_entry(entry, &mut ids)?
|
||||
}
|
||||
LogEntry::RunCompleted {
|
||||
entry_id: Some(id), ..
|
||||
} => {
|
||||
if !portable_id(&id.0) || !ids.insert(id.0.clone()) {
|
||||
return Err(reject("duplicate or invalid entry identity"));
|
||||
}
|
||||
}
|
||||
LogEntry::Invoke { .. } | LogEntry::LlmUsage { .. } | LogEntry::TurnEnd { .. } => {}
|
||||
_ => {
|
||||
return Err(reject(
|
||||
"unsupported conversation record or pending operation",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let state = session_store::collect_state(&logs);
|
||||
if state.last_run_interrupted || state.active_run_turn_count.is_some() {
|
||||
return Err(reject("Session has pending execution"));
|
||||
}
|
||||
for segments in &state.user_segments {
|
||||
if segments
|
||||
.iter()
|
||||
.any(|segment| !matches!(segment, protocol::Segment::Text { .. }))
|
||||
{
|
||||
return Err(reject("Session attachments are unsupported"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Compare *fields*, not re-encoded bytes or scalar values (e.g. f32 JSON
|
||||
// normalization). Serde's permissive/untagged/flattened compatibility readers
|
||||
// can drop unknown data without surfacing it to an ignored-field callback. A
|
||||
// retained field must survive the owner's typed projection at every depth.
|
||||
// This intentionally accepts only canonical writer shapes: even an explicit
|
||||
// empty optional field omitted by that writer is rejected, not repaired.
|
||||
// The projection is validation-only; installation always writes original bytes.
|
||||
fn validate_preserved_record(bytes: &[u8]) -> Result<(), RuntimeError> {
|
||||
let original: serde_json::Value = decode(bytes)?;
|
||||
let record: session_store::LogEntry = decode(bytes)?;
|
||||
let projection =
|
||||
serde_json::to_value(&record).map_err(|_| reject("invalid typed Session record"))?;
|
||||
require_preserved_fields(&original, &projection)
|
||||
}
|
||||
|
||||
fn require_preserved_fields(
|
||||
original: &serde_json::Value,
|
||||
projection: &serde_json::Value,
|
||||
) -> Result<(), RuntimeError> {
|
||||
use serde_json::Value;
|
||||
match (original, projection) {
|
||||
(Value::Object(original), Value::Object(projection)) => {
|
||||
for (key, value) in original {
|
||||
let projected = projection
|
||||
.get(key)
|
||||
.ok_or_else(|| reject("unknown or noncanonical Session field"))?;
|
||||
require_preserved_fields(value, projected)?;
|
||||
}
|
||||
}
|
||||
(Value::Array(original), Value::Array(projection))
|
||||
if original.len() == projection.len() =>
|
||||
{
|
||||
for (value, projected) in original.iter().zip(projection) {
|
||||
require_preserved_fields(value, projected)?;
|
||||
}
|
||||
}
|
||||
(Value::Null, Value::Null)
|
||||
| (Value::Bool(_), Value::Bool(_))
|
||||
| (Value::Number(_), Value::Number(_))
|
||||
| (Value::String(_), Value::String(_)) => {}
|
||||
_ => return Err(reject("noncanonical Session field shape")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Exact receipt wire fields owned by worker::PendingActivationState. Keep these
|
||||
// fixture-only mirrors strict; do not change the production compatibility reader.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubmissionReceipt {
|
||||
submission_request_id: String,
|
||||
source_namespace: String,
|
||||
submission_id: String,
|
||||
payload_digest: String,
|
||||
disposition: protocol::SubmissionDisposition,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct NotificationReceipt {
|
||||
notification_request_id: String,
|
||||
source_namespace: String,
|
||||
payload_digest: String,
|
||||
}
|
||||
|
||||
// The approved conversation has committed submission checkpoints in its original
|
||||
// Session records. Keep their receipt bytes, but reject any resumable queue or
|
||||
// Feature invocation history. This projection confers no execution authority.
|
||||
fn validate_settled_activation(
|
||||
extension: &session_store::SessionExtension,
|
||||
) -> Result<(), RuntimeError> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Checkpoint {
|
||||
#[serde(rename = "next_activation_sequence")]
|
||||
_next_activation_sequence: u64,
|
||||
activating: Option<serde::de::IgnoredAny>,
|
||||
activating_notification: Vec<serde::de::IgnoredAny>,
|
||||
pending: Vec<serde::de::IgnoredAny>,
|
||||
pending_notifications: Vec<serde::de::IgnoredAny>,
|
||||
#[serde(rename = "receipts")]
|
||||
_receipts: Vec<SubmissionReceipt>,
|
||||
#[serde(rename = "notification_receipts")]
|
||||
_notification_receipts: Vec<NotificationReceipt>,
|
||||
invocation_receipts: Vec<serde::de::IgnoredAny>,
|
||||
invocation_recovery_blocked: bool,
|
||||
}
|
||||
if extension.domain != "worker.pending_activations.v1" {
|
||||
return Err(reject("unsupported Session extension"));
|
||||
}
|
||||
let checkpoint: Checkpoint = serde_json::from_value(extension.payload.clone())
|
||||
.map_err(|_| reject("invalid Session activation checkpoint"))?;
|
||||
if checkpoint.activating.is_some()
|
||||
|| !checkpoint.activating_notification.is_empty()
|
||||
|| !checkpoint.pending.is_empty()
|
||||
|| !checkpoint.pending_notifications.is_empty()
|
||||
|| !checkpoint.invocation_receipts.is_empty()
|
||||
|| checkpoint.invocation_recovery_blocked
|
||||
{
|
||||
return Err(reject(
|
||||
"Session has pending activation or invocation history",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_request(request: &CreateWorkerRequest, install: bool) -> Result<(), RuntimeError> {
|
||||
if !request.workdir_attachment_requests.is_empty()
|
||||
|| !request.workdir_attachments.is_empty()
|
||||
|| request.backend_job.is_some()
|
||||
|| request.subjektiv_attached
|
||||
|| request.worker_observation_enabled
|
||||
|| !request.worker_observation_grants.is_empty()
|
||||
|| (install && request.initial_input.is_some())
|
||||
{
|
||||
return Err(reject(
|
||||
"unsupported capabilities, attachments, or initial input",
|
||||
));
|
||||
}
|
||||
if install
|
||||
&& (request.create_fingerprint.trim().is_empty()
|
||||
|| request.config_bundle.is_none()
|
||||
|| !matches!(
|
||||
request.profile_source,
|
||||
ProfileSourceArchiveSource::WorkspaceConfig { .. }
|
||||
))
|
||||
{
|
||||
return Err(reject(
|
||||
"fresh config/profile references and create fingerprint are required",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_empty_artifacts(root: &Path) -> Result<(), RuntimeError> {
|
||||
for entry in io(fs::read_dir(root))? {
|
||||
let entry = io(entry)?;
|
||||
let metadata = io(entry.metadata())?;
|
||||
if metadata.is_dir() {
|
||||
check_empty_artifacts(&entry.path())?;
|
||||
} else if entry.file_name() != ".aggregate.lock" || metadata.len() != 0 {
|
||||
return Err(reject("Session artifacts are unsupported"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<(), RuntimeError> {
|
||||
let mut file = io(OpenOptions::new().write(true).create_new(true).open(path))?;
|
||||
io(file.write_all(bytes))?;
|
||||
io(file.sync_all())
|
||||
}
|
||||
|
||||
// A streaming validation visitor, not a generic document available to callers.
|
||||
struct PortableJson;
|
||||
impl<'de> Deserialize<'de> for PortableJson {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct Visitor;
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = PortableJson;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("portable fixture JSON")
|
||||
}
|
||||
fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<PortableJson, E> {
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<PortableJson, E> {
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<PortableJson, E> {
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<PortableJson, E> {
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<PortableJson, E> {
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<PortableJson, E> {
|
||||
let lower = value.to_ascii_lowercase();
|
||||
if [
|
||||
"://",
|
||||
"private key",
|
||||
"bearer ",
|
||||
"sk-",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"private_key",
|
||||
"authorization",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker))
|
||||
|| value
|
||||
.split_whitespace()
|
||||
.any(|word| word.starts_with('/') || word.contains(":\\"))
|
||||
{
|
||||
return Err(E::custom("nonportable or sensitive fixture material"));
|
||||
}
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(
|
||||
self,
|
||||
mut seq: A,
|
||||
) -> Result<PortableJson, A::Error> {
|
||||
while seq.next_element::<PortableJson>()?.is_some() {}
|
||||
Ok(PortableJson)
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(
|
||||
self,
|
||||
mut map: A,
|
||||
) -> Result<PortableJson, A::Error> {
|
||||
let mut keys = std::collections::BTreeSet::new();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
// A duplicate key can hide discarded data even inside an
|
||||
// otherwise opaque extension payload. Never retain it.
|
||||
if !keys.insert(key.clone()) {
|
||||
return Err(serde::de::Error::custom("duplicate fixture JSON field"));
|
||||
}
|
||||
serde::de::Visitor::visit_str::<A::Error>(Visitor, &key)?;
|
||||
map.next_value::<PortableJson>()?;
|
||||
}
|
||||
Ok(PortableJson)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(Visitor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
use super::*;
|
||||
use crate::auth::RuntimeIdentityMaterial;
|
||||
use crate::catalog::{ProfileSelector, WorkspaceApiRef};
|
||||
use crate::profile_archive::ProfileSourceGraphSummary;
|
||||
use session_store::{LogEntry, Store};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const RUNTIME: &str = "fixture-runtime";
|
||||
const WORKSPACE: &str = "fixture-workspace";
|
||||
const NEW_URL: &str = "http://127.0.0.1:34567";
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum ReceiptFixture<T> {
|
||||
Valid(T),
|
||||
Malformed(u8),
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
struct CheckpointFixture {
|
||||
next_activation_sequence: u64,
|
||||
activating: Option<u8>,
|
||||
activating_notification: Vec<u8>,
|
||||
pending: Vec<u8>,
|
||||
pending_notifications: Vec<u8>,
|
||||
receipts: Vec<ReceiptFixture<SubmissionReceipt>>,
|
||||
notification_receipts: Vec<ReceiptFixture<NotificationReceipt>>,
|
||||
invocation_receipts: Vec<u8>,
|
||||
invocation_recovery_blocked: bool,
|
||||
}
|
||||
|
||||
fn settled_checkpoint() -> CheckpointFixture {
|
||||
CheckpointFixture {
|
||||
next_activation_sequence: 2,
|
||||
receipts: vec![ReceiptFixture::Valid(SubmissionReceipt {
|
||||
submission_request_id: "completed-submission-request".into(),
|
||||
source_namespace: "fixture-source".into(),
|
||||
submission_id: "completed-submission".into(),
|
||||
payload_digest: "fixture-payload-digest".into(),
|
||||
disposition: protocol::SubmissionDisposition::Started,
|
||||
})],
|
||||
notification_receipts: vec![ReceiptFixture::Valid(NotificationReceipt {
|
||||
notification_request_id: "completed-notification-request".into(),
|
||||
source_namespace: "fixture-source".into(),
|
||||
payload_digest: "fixture-notification-digest".into(),
|
||||
})],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint_extension(checkpoint: &CheckpointFixture) -> session_store::SessionExtension {
|
||||
session_store::SessionExtension::new(
|
||||
"worker.pending_activations.v1",
|
||||
serde_json::to_value(checkpoint).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn worker_id() -> WorkerId {
|
||||
"01a1657d-b762-7791-87ae-a49f28cadbce".parse().unwrap()
|
||||
}
|
||||
|
||||
fn resolved() -> manifest::WorkerManifest {
|
||||
let mut config = manifest::WorkerManifestConfig::resolution_defaults();
|
||||
config.worker.name = Some(runtime_worker_name(worker_id()));
|
||||
manifest::WorkerManifest::try_from(config).unwrap()
|
||||
}
|
||||
|
||||
fn request() -> CreateWorkerRequest {
|
||||
CreateWorkerRequest {
|
||||
worker_id: worker_id(),
|
||||
create_fingerprint: "new-create-fingerprint".into(),
|
||||
profile: ProfileSelector::Builtin("default".into()),
|
||||
display_name: Some("fixture".into()),
|
||||
profile_source: ProfileSourceArchiveSource::WorkspaceConfig {
|
||||
archive: ProfileSourceArchiveRef {
|
||||
id: "new-profile".into(),
|
||||
digest: "new-profile-digest".into(),
|
||||
size_bytes: 1,
|
||||
source_graph: ProfileSourceGraphSummary {
|
||||
source_count: 1,
|
||||
total_source_bytes: 1,
|
||||
entrypoints: BTreeMap::from([("builtin:default".into(), "default.dcl".into())]),
|
||||
import_count: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
config_bundle: Some(ConfigBundleRef {
|
||||
id: "new-config".into(),
|
||||
digest: "new-config-digest".into(),
|
||||
}),
|
||||
workspace_api: Some(WorkspaceApiRef {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
base_url: NEW_URL.into(),
|
||||
}),
|
||||
initial_input: None,
|
||||
workdir_attachment_requests: Vec::new(),
|
||||
workdir_attachments: Vec::new(),
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: Vec::new(),
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
language: "en".into(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
backend_job: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize(root: &Path, runtime_id: &str, workspace_id: &str, url: &str) {
|
||||
#[derive(Serialize)]
|
||||
struct Auth {
|
||||
identity: RuntimeIdentityMaterial,
|
||||
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
|
||||
}
|
||||
let issuer = RuntimeIdentityMaterial::generate("fixture-issuer").unwrap();
|
||||
let digest = Sha256::digest(crate::auth::decode_public_key(&issuer.public_key).unwrap());
|
||||
let fingerprint = format!(
|
||||
"sha256:{}",
|
||||
digest
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
let trust = WorkspaceIssuerTrustRecord::from_bundle(
|
||||
server_api::WorkspacePublicIdentityBundle {
|
||||
workspace_id: workspace_id.into(),
|
||||
backend_url: url.into(),
|
||||
key_id: "fixture-issuer".into(),
|
||||
algorithm: "ed25519".into(),
|
||||
public_key: issuer.public_key,
|
||||
public_key_fingerprint: fingerprint,
|
||||
},
|
||||
"fixture-trust".into(),
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(root).unwrap();
|
||||
fs::write(
|
||||
root.join("auth.toml"),
|
||||
toml::to_string(&Auth {
|
||||
identity: RuntimeIdentityMaterial::generate(runtime_id).unwrap(),
|
||||
workspace_issuers: vec![trust],
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn record(request: CreateWorkerRequest) -> PersistedWorkerRecord {
|
||||
PersistedWorkerRecord {
|
||||
restore_guard: crate::runtime::RestoreGuard::default(),
|
||||
worker_ref: WorkerRef::new(request.worker_id),
|
||||
worker_id: request.worker_id,
|
||||
profile: request.profile.clone(),
|
||||
display_name: request.display_name.clone(),
|
||||
profile_source: request.profile_source.reference(),
|
||||
config_bundle: request.config_bundle.clone(),
|
||||
created_at_ms: Some(123456),
|
||||
status: WorkerStatus::Stopped,
|
||||
last_finished_submission_request_id: Some("old-submission-operation".into()),
|
||||
workspace_id: Some(WORKSPACE.into()),
|
||||
workdir_attachments: vec![],
|
||||
logical_workdir_attachments: vec![],
|
||||
execution_state: PersistedWorkerExecutionState::Available(PersistedWorkerExecution {
|
||||
request,
|
||||
binding: Some(PersistedWorkerExecutionBinding {}),
|
||||
restore_intent: WorkerRestoreIntent::Explicit,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn source() -> (tempfile::TempDir, PathBuf, StoppedWorkerTemplate) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime");
|
||||
initialize(&root, RUNTIME, WORKSPACE, "http://127.0.0.1:23456");
|
||||
drop(
|
||||
crate::Runtime::with_fs_store(FsRuntimeStoreOptions::new(&root).with_runtime_id(RUNTIME))
|
||||
.unwrap(),
|
||||
);
|
||||
let opened = FsRuntimeStore::open_or_create(root.clone(), RUNTIME).unwrap();
|
||||
let mut old = request();
|
||||
old.workspace_api.as_mut().unwrap().base_url = "http://127.0.0.1:23456".into();
|
||||
old.config_bundle.as_mut().unwrap().id = "old-config".into();
|
||||
opened.store.write_worker_record(&record(old)).unwrap();
|
||||
let worker = opened.store.worker_dir(&worker_id());
|
||||
let session_id = "01a1657d-b800-7777-8888-111111111111".parse().unwrap();
|
||||
let segment_id = "01a1657d-b818-77c1-bd73-176172a4bd82".parse().unwrap();
|
||||
let mut metadata = WorkerMetadata::new(
|
||||
runtime_worker_name(worker_id()),
|
||||
Some(WorkerActiveSegmentRef {
|
||||
session_id,
|
||||
segment_id: Some(segment_id),
|
||||
}),
|
||||
)
|
||||
.with_workspace_id(WORKSPACE)
|
||||
.with_workspace_root(PathBuf::from("/old/host/path"));
|
||||
metadata.resolved_manifest_snapshot =
|
||||
Some(manifest::write_persisted_worker_manifest_snapshot(&resolved()).unwrap());
|
||||
atomic_write_json(
|
||||
&worker.join(WORKER_METADATA_FILE),
|
||||
&metadata,
|
||||
"test metadata",
|
||||
)
|
||||
.unwrap();
|
||||
let sessions = WorkerSessionStore::new(worker.join("session")).unwrap();
|
||||
let history = session_store::LoggedHistoryEntry {
|
||||
item: session_store::LoggedItem::Message {
|
||||
role: session_store::LoggedRole::Assistant,
|
||||
content: vec![session_store::LoggedContentPart::Text {
|
||||
text: "artificial fixture reply".into(),
|
||||
}],
|
||||
},
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId("fixed-entry-id".into()),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::ModelOutput {
|
||||
worker: session_store::LoggedWorkerSubject {
|
||||
workspace_id: Some(WORKSPACE.into()),
|
||||
runtime_id: Some(RUNTIME.into()),
|
||||
worker_id: worker_id().to_string(),
|
||||
},
|
||||
},
|
||||
derivation: None,
|
||||
input_metadata: None,
|
||||
},
|
||||
};
|
||||
sessions
|
||||
.create_segment(
|
||||
session_id,
|
||||
segment_id,
|
||||
&[
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 123,
|
||||
session_id,
|
||||
system_prompt: Some("Harmless artificial fixture instructions.".into()),
|
||||
config: Default::default(),
|
||||
history: vec![],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::Invoke {
|
||||
ts: 124,
|
||||
trigger: protocol::InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 124,
|
||||
segments: vec![protocol::Segment::text(
|
||||
"Harmless artificial fixture input.",
|
||||
)],
|
||||
extensions: vec![checkpoint_extension(&settled_checkpoint())],
|
||||
history: vec![session_store::LoggedHistoryEntry {
|
||||
item: session_store::LoggedItem::Message {
|
||||
role: session_store::LoggedRole::User,
|
||||
content: vec![session_store::LoggedContentPart::Text {
|
||||
text: "Harmless artificial fixture input.".into(),
|
||||
}],
|
||||
},
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId(
|
||||
"fixed-user-id".into(),
|
||||
),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::BackendInstruction {
|
||||
operation_id: None,
|
||||
},
|
||||
derivation: None,
|
||||
input_metadata: None,
|
||||
},
|
||||
}],
|
||||
},
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 125,
|
||||
entry: history,
|
||||
},
|
||||
LogEntry::LlmUsage {
|
||||
ts: 125,
|
||||
history_len: 2,
|
||||
input_total_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 1,
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 126,
|
||||
turn_count: 1,
|
||||
},
|
||||
LogEntry::RunCompleted {
|
||||
entry_id: Some(session_store::LoggedSessionHistoryEntryId(
|
||||
"fixed-terminal-id".into(),
|
||||
)),
|
||||
ts: 126,
|
||||
interrupted: false,
|
||||
result: agen::EngineResult::Finished,
|
||||
active_run_turn_count: None,
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(worker.join("runs/old-run")).unwrap();
|
||||
fs::write(worker.join("runs/old-run/private.log"), "not exported").unwrap();
|
||||
drop(opened);
|
||||
let template = StoppedWorkerTemplate::extract(&worker).unwrap();
|
||||
(temp, worker, template)
|
||||
}
|
||||
|
||||
fn destination(template: &StoppedWorkerTemplate) -> (tempfile::TempDir, FsRuntimeStoreOptions) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("runtime");
|
||||
initialize(
|
||||
&root,
|
||||
template.runtime_id(),
|
||||
template.workspace_id(),
|
||||
NEW_URL,
|
||||
);
|
||||
(
|
||||
temp,
|
||||
FsRuntimeStoreOptions::new(root).with_runtime_id(template.runtime_id()),
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_fresh(options: &FsRuntimeStoreOptions) {
|
||||
assert_eq!(fs::read_dir(&options.root).unwrap().count(), 1);
|
||||
assert!(options.root.join("auth.toml").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extraction_install_roundtrip_preserves_bytes_and_is_inactive() {
|
||||
let (_source, worker, template) = source();
|
||||
let original_worker = fs::read(worker.join(WORKER_FILE)).unwrap();
|
||||
let original_metadata = fs::read(worker.join(WORKER_METADATA_FILE)).unwrap();
|
||||
let exported = serde_json::to_vec(&template).unwrap();
|
||||
let exported_text = String::from_utf8(exported.clone()).unwrap();
|
||||
for forbidden in [
|
||||
"old-config",
|
||||
"old-submission-operation",
|
||||
"restore_guard",
|
||||
"private_key",
|
||||
"http://",
|
||||
"/old/host",
|
||||
"private.log",
|
||||
"resolved_manifest_snapshot",
|
||||
] {
|
||||
assert!(!exported_text.contains(forbidden));
|
||||
}
|
||||
let template: StoppedWorkerTemplate = serde_json::from_slice(&exported).unwrap();
|
||||
let (_target, options) = destination(&template);
|
||||
let auth_before = fs::read(options.root.join("auth.toml")).unwrap();
|
||||
template.install(&options, request(), &resolved()).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(options.root.join("auth.toml")).unwrap(),
|
||||
auth_before
|
||||
);
|
||||
assert_eq!(fs::read(worker.join(WORKER_FILE)).unwrap(), original_worker);
|
||||
assert_eq!(
|
||||
fs::read(worker.join(WORKER_METADATA_FILE)).unwrap(),
|
||||
original_metadata
|
||||
);
|
||||
let installed = options
|
||||
.root
|
||||
.join(WORKERS_DIR)
|
||||
.join(template.worker_id().to_string());
|
||||
assert_eq!(
|
||||
fs::read(installed.join("session/session.json")).unwrap(),
|
||||
template.session_manifest
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(installed.join(format!("session/segments/{}.jsonl", template.segment_id())))
|
||||
.unwrap(),
|
||||
template.segment_jsonl
|
||||
);
|
||||
assert!(!installed.join("runs").exists());
|
||||
let metadata: WorkerMetadata =
|
||||
decode(&fs::read(installed.join(WORKER_METADATA_FILE)).unwrap()).unwrap();
|
||||
assert_eq!(metadata.active.unwrap().session_id, template.session_id());
|
||||
assert!(metadata.workspace_root.is_none() && metadata.resolved_manifest_snapshot.is_some());
|
||||
|
||||
let opened = FsRuntimeStore::open_or_create(options.root.clone(), RUNTIME).unwrap();
|
||||
let state = opened.state.unwrap();
|
||||
assert!(state.diagnostics.is_empty());
|
||||
let saved = state.workers.get(&worker_id()).unwrap();
|
||||
assert_eq!(saved.status, WorkerStatus::Stopped);
|
||||
assert!(saved.last_finished_submission_request_id.is_none());
|
||||
let PersistedWorkerExecutionState::Available(execution) = &saved.execution_state else {
|
||||
panic!("not available")
|
||||
};
|
||||
assert_eq!(execution.restore_intent, WorkerRestoreIntent::Explicit);
|
||||
assert!(execution.binding.is_none());
|
||||
assert_eq!(execution.request, request());
|
||||
// Fresh restore guard, no operation owner/receipt history.
|
||||
assert_ne!(
|
||||
serde_json::to_vec(&saved.restore_guard).unwrap(),
|
||||
serde_json::to_vec(
|
||||
&decode::<WorkerAggregateRecord>(&original_worker)
|
||||
.unwrap()
|
||||
.restore_guard
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let guard: GuardState = decode(&serde_json::to_vec(&saved.restore_guard).unwrap()).unwrap();
|
||||
assert!(guard.active_request_id.is_none() && guard.owners.is_empty());
|
||||
drop(opened.store);
|
||||
|
||||
struct NoExecution;
|
||||
impl crate::execution::WorkerExecutionBackend for NoExecution {
|
||||
fn backend_id(&self) -> &str {
|
||||
"fixture-no-execution"
|
||||
}
|
||||
fn spawn_worker(
|
||||
&self,
|
||||
_: crate::execution::WorkerExecutionSpawnRequest,
|
||||
) -> crate::execution::WorkerExecutionSpawnResult {
|
||||
panic!("fixture executed")
|
||||
}
|
||||
fn dispatch_input(
|
||||
&self,
|
||||
_: &WorkerRef,
|
||||
_: crate::interaction::WorkerInput,
|
||||
) -> crate::execution::WorkerExecutionResult {
|
||||
panic!("fixture submitted")
|
||||
}
|
||||
fn restore_worker(
|
||||
&self,
|
||||
_: crate::execution::WorkerExecutionRestoreRequest,
|
||||
) -> crate::execution::WorkerExecutionSpawnResult {
|
||||
panic!("fixture auto-restored")
|
||||
}
|
||||
}
|
||||
let runtime =
|
||||
crate::Runtime::with_fs_store_and_execution_backend(options.clone(), Arc::new(NoExecution))
|
||||
.unwrap();
|
||||
assert_eq!(runtime.list_workers().unwrap().len(), 1);
|
||||
assert_eq!(runtime.summary().unwrap().active_worker_count, 0);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.worker_detail(&WorkerRef::new(worker_id()))
|
||||
.unwrap()
|
||||
.status,
|
||||
WorkerStatus::Stopped
|
||||
);
|
||||
drop(runtime);
|
||||
let runtime = crate::Runtime::with_fs_store(options).unwrap();
|
||||
assert_eq!(
|
||||
runtime.list_workers().unwrap()[0].status,
|
||||
WorkerStatus::Stopped
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_schema_identity_input_and_overwrite() {
|
||||
let (_source, _, template) = source();
|
||||
for change in [
|
||||
"template",
|
||||
"runtime-schema",
|
||||
"session-schema",
|
||||
"session-id",
|
||||
"segment-id",
|
||||
"runtime-id",
|
||||
"workspace-id",
|
||||
"worker-id",
|
||||
"origin-id",
|
||||
"duplicate-id",
|
||||
"truncated",
|
||||
] {
|
||||
let (_target, options) = destination(&template);
|
||||
let mut invalid = template.clone();
|
||||
match change {
|
||||
"template" => invalid.template_version += 1,
|
||||
"runtime-schema" => invalid.runtime_schema_version += 1,
|
||||
"session-schema" => {
|
||||
invalid.session_manifest = format!(
|
||||
"{{\"schema_version\":999,\"session_id\":\"{}\"}}",
|
||||
invalid.session_id
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
"session-id" => invalid.session_id = uuid::Uuid::now_v7(),
|
||||
"segment-id" => invalid.segment_id = uuid::Uuid::nil(), // no external attestation: valid new Segment ID is otherwise portable
|
||||
"runtime-id" => invalid.runtime_id = "other-runtime".into(),
|
||||
"workspace-id" => invalid.workspace_id = "other-workspace".into(),
|
||||
"worker-id" => invalid.worker_id = WorkerId::now_v7(),
|
||||
"origin-id" => {
|
||||
invalid.segment_jsonl = String::from_utf8(invalid.segment_jsonl)
|
||||
.unwrap()
|
||||
.replace(RUNTIME, "other-runtime")
|
||||
.into_bytes()
|
||||
}
|
||||
"duplicate-id" => {
|
||||
let last = invalid
|
||||
.segment_jsonl
|
||||
.split_inclusive(|b| *b == b'\n')
|
||||
.last()
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
invalid.segment_jsonl.extend(last);
|
||||
}
|
||||
"truncated" => {
|
||||
invalid.segment_jsonl.pop();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(
|
||||
invalid.install(&options, request(), &resolved()).is_err(),
|
||||
"accepted {change}"
|
||||
);
|
||||
assert_fresh(&options);
|
||||
}
|
||||
let (_target, options) = destination(&template);
|
||||
let mut launch = request();
|
||||
launch.initial_input = Some(crate::interaction::WorkerInput::user("do not execute"));
|
||||
assert!(template.install(&options, launch, &resolved()).is_err());
|
||||
assert_fresh(&options);
|
||||
template.install(&options, request(), &resolved()).unwrap();
|
||||
let worker_path = options
|
||||
.root
|
||||
.join(WORKERS_DIR)
|
||||
.join(worker_id().to_string())
|
||||
.join(WORKER_FILE);
|
||||
let before = fs::read(&worker_path).unwrap();
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
assert_eq!(fs::read(worker_path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_uninitialized_untrusted_live_and_partial_destination() {
|
||||
let (_source, _, template) = source();
|
||||
let (_target, options) = destination(&template);
|
||||
let held = acquire_runtime_store_owner_lock(&options.root).unwrap();
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
assert_fresh(&options);
|
||||
drop(held);
|
||||
initialize(&options.root, "wrong-runtime", WORKSPACE, NEW_URL);
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
initialize(&options.root, RUNTIME, WORKSPACE, "http://127.0.0.1:23456");
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
fs::remove_file(options.root.join("auth.toml")).unwrap();
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
initialize(&options.root, RUNTIME, WORKSPACE, NEW_URL);
|
||||
fs::create_dir(options.root.join(WORKERS_DIR)).unwrap();
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extraction_rejects_nonstopped_pending_schema_and_metadata_mismatch() {
|
||||
for change in [
|
||||
"running",
|
||||
"pending",
|
||||
"guard-pending",
|
||||
"attachment",
|
||||
"schema",
|
||||
"worker-id",
|
||||
"metadata-workspace",
|
||||
"metadata-session",
|
||||
"artifact",
|
||||
"secret",
|
||||
"url",
|
||||
"path",
|
||||
] {
|
||||
let (_source, worker, _) = source();
|
||||
let mut aggregate: WorkerAggregateRecord =
|
||||
decode(&fs::read(worker.join(WORKER_FILE)).unwrap()).unwrap();
|
||||
match change {
|
||||
"running" => aggregate.status = WorkerStatus::Running,
|
||||
"pending" => {
|
||||
aggregate.execution_state = WorkerExecutionStateRecord::ReconciliationRequired(
|
||||
PersistedWorkerLifecycleOperation::Restore(PersistedWorkerRestoreOperation {
|
||||
operation_id: WorkerLifecycleOperationId::new(),
|
||||
mode: PersistedWorkerRestoreMode::Explicit,
|
||||
request: request(),
|
||||
binding: None,
|
||||
last_settled_status: WorkerStatus::Stopped,
|
||||
}),
|
||||
)
|
||||
}
|
||||
"guard-pending" => aggregate.restore_guard = serde_json::from_str(r#"{"observation_token":"old-token","active_request_id":"pending-restore","owners":{}}"#).unwrap(),
|
||||
"attachment" => aggregate.logical_workdir_attachments.push(LogicalWorkdirAttachment {
|
||||
alias: workdir::WorkdirAttachmentAlias::new("source").unwrap(),
|
||||
working_directory_id: "old-workdir".into(),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
}),
|
||||
"schema" => aggregate.schema_version += 1,
|
||||
"worker-id" => aggregate.worker_id = WorkerId::now_v7(),
|
||||
"metadata-workspace" | "metadata-session" => {
|
||||
let mut metadata: WorkerMetadata =
|
||||
decode(&fs::read(worker.join(WORKER_METADATA_FILE)).unwrap()).unwrap();
|
||||
if change == "metadata-workspace" {
|
||||
metadata.workspace_id = Some("wrong-workspace".into());
|
||||
} else {
|
||||
metadata.active.as_mut().unwrap().session_id = uuid::Uuid::now_v7();
|
||||
}
|
||||
atomic_write_json(
|
||||
&worker.join(WORKER_METADATA_FILE),
|
||||
&metadata,
|
||||
"test mismatch",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"artifact" => {
|
||||
fs::create_dir_all(worker.join("session/artifacts/paste")).unwrap();
|
||||
fs::write(
|
||||
worker.join("session/artifacts/paste/payload"),
|
||||
"unsupported",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"secret" | "url" | "path" => {
|
||||
let segment = fs::read_dir(worker.join("session/segments"))
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let replacement = match change {
|
||||
"secret" => "Bearer secret",
|
||||
"url" => "https://old-host.invalid",
|
||||
_ => "/old/host/path",
|
||||
};
|
||||
let text = fs::read_to_string(&segment)
|
||||
.unwrap()
|
||||
.replace("artificial fixture reply", replacement);
|
||||
fs::write(segment, text).unwrap();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
atomic_write_json(&worker.join(WORKER_FILE), &aggregate, "test aggregate").unwrap();
|
||||
assert!(
|
||||
StoppedWorkerTemplate::extract(&worker).is_err(),
|
||||
"accepted {change}"
|
||||
);
|
||||
}
|
||||
let (_source, worker, _) = source();
|
||||
let held =
|
||||
acquire_runtime_store_owner_lock(worker.parent().unwrap().parent().unwrap()).unwrap();
|
||||
assert!(StoppedWorkerTemplate::extract(&worker).is_err());
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_manifest_and_memory_settings_are_required() {
|
||||
let (_source, _, template) = source();
|
||||
let (_target, options) = destination(&template);
|
||||
let mut manifest = resolved();
|
||||
manifest.worker.name = "wrong-worker".into();
|
||||
assert!(template.install(&options, request(), &manifest).is_err());
|
||||
let mut binding = request();
|
||||
binding.memory_settings = None;
|
||||
assert!(template.install(&options, binding, &resolved()).is_err());
|
||||
let mut binding = request();
|
||||
binding.memory_settings.as_mut().unwrap().workspace_id = "other-workspace".into();
|
||||
assert!(template.install(&options, binding, &resolved()).is_err());
|
||||
assert_fresh(&options);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settled_checkpoints_preserve_receipts_but_refuse_pending_work() {
|
||||
let mut checkpoint = settled_checkpoint();
|
||||
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_ok());
|
||||
checkpoint.pending.push(1);
|
||||
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
|
||||
checkpoint.pending.clear();
|
||||
checkpoint.activating = Some(1);
|
||||
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
|
||||
checkpoint.activating = None;
|
||||
checkpoint.invocation_receipts.push(1);
|
||||
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
|
||||
checkpoint.invocation_receipts.clear();
|
||||
checkpoint.invocation_recovery_blocked = true;
|
||||
assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err());
|
||||
}
|
||||
|
||||
// Deliberately corrupt only test-owned synthetic source files, never the lab.
|
||||
fn assert_rejected_session_bytes(worker: &Path, template: &StoppedWorkerTemplate, bytes: Vec<u8>) {
|
||||
let path = worker.join(format!("session/segments/{}.jsonl", template.segment_id()));
|
||||
fs::write(path, &bytes).unwrap();
|
||||
let extraction_error = StoppedWorkerTemplate::extract(worker)
|
||||
.err()
|
||||
.expect("invalid source accepted");
|
||||
assert!(
|
||||
!extraction_error
|
||||
.to_string()
|
||||
.contains("synthetic-discarded-material")
|
||||
);
|
||||
let (_target, options) = destination(template);
|
||||
let mut invalid = template.clone();
|
||||
invalid.segment_jsonl = bytes;
|
||||
let installation_error = invalid
|
||||
.install(&options, request(), &resolved())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
!installation_error
|
||||
.to_string()
|
||||
.contains("synthetic-discarded-material")
|
||||
);
|
||||
assert_fresh(&options);
|
||||
}
|
||||
|
||||
fn synthetic_logs(template: &StoppedWorkerTemplate) -> Vec<LogEntry> {
|
||||
template
|
||||
.segment_jsonl
|
||||
.split(|byte| *byte == b'\n')
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| serde_json::from_slice(line).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn encode_synthetic_logs(logs: &[LogEntry]) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
for log in logs {
|
||||
serde_json::to_writer(&mut bytes, log).unwrap();
|
||||
bytes.push(b'\n');
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_system_prompt_matches_worker_restore_rejection() {
|
||||
let (_source, worker_dir, template) = source();
|
||||
let mut logs = synthetic_logs(&template);
|
||||
assert!(session_store::collect_state(&logs).system_prompt.is_some());
|
||||
let LogEntry::AnnotatedSegmentStart { system_prompt, .. } = &mut logs[0] else {
|
||||
panic!("missing seed")
|
||||
};
|
||||
*system_prompt = None;
|
||||
assert_rejected_session_bytes(&worker_dir, &template, encode_synthetic_logs(&logs));
|
||||
// The owning Worker constructor rejects this before model-client creation,
|
||||
// allocation, or execution. Do not start a controller or submit any input.
|
||||
let store = session_store::CombinedStore::new(
|
||||
WorkerSessionStore::open_read_only(worker_dir.join("session")).unwrap(),
|
||||
session_store::WorkerAggregateStore::open_read_only(&worker_dir, template.worker_name())
|
||||
.unwrap(),
|
||||
);
|
||||
let restored = worker::Worker::restore_from_manifest(
|
||||
template.session_id(),
|
||||
template.segment_id(),
|
||||
resolved(),
|
||||
store,
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
restored,
|
||||
Err(worker::WorkerError::SegmentSystemPromptMissing { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_submission_and_notification_receipts_fail_extraction_and_install() {
|
||||
let (_source, worker, template) = source();
|
||||
for notification in [false, true] {
|
||||
let mut checkpoint = settled_checkpoint();
|
||||
if notification {
|
||||
checkpoint.notification_receipts =
|
||||
vec![ReceiptFixture::Malformed(1), ReceiptFixture::Malformed(2)];
|
||||
} else {
|
||||
checkpoint.receipts = vec![ReceiptFixture::Malformed(1), ReceiptFixture::Malformed(2)];
|
||||
}
|
||||
let mut logs = synthetic_logs(&template);
|
||||
for log in &mut logs {
|
||||
if let LogEntry::AnnotatedUserInput { extensions, .. } = log {
|
||||
*extensions = vec![checkpoint_extension(&checkpoint)];
|
||||
}
|
||||
}
|
||||
assert_rejected_session_bytes(&worker, &template, encode_synthetic_logs(&logs));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_at_every_retained_record_depth_are_rejected_without_secret_diagnostics() {
|
||||
let (_source, worker, template) = source();
|
||||
let original = String::from_utf8(template.segment_jsonl.clone()).unwrap();
|
||||
// These values are harmless synthetic markers, not lab/private material.
|
||||
// The compatibility decoder normally discards many of these fields.
|
||||
let locations = [
|
||||
r#""kind":"annotated_segment_start""#,
|
||||
r#""kind":"invoke""#,
|
||||
r#""kind":"annotated_user_input""#,
|
||||
r#""kind":"annotated_assistant_item""#,
|
||||
r#""kind":"llm_usage""#,
|
||||
r#""kind":"turn_end""#,
|
||||
r#""kind":"run_completed""#,
|
||||
r#""max_tokens":null"#,
|
||||
r#""kind":"message","role":"assistant""#,
|
||||
r#""kind":"text","text":"artificial fixture reply""#,
|
||||
r#""kind":"text","content":"Harmless artificial fixture input.""#,
|
||||
r#""entry_id":"fixed-entry-id""#,
|
||||
r#""kind":"model_output""#,
|
||||
r#""worker_id":"01a1657d-b762-7791-87ae-a49f28cadbce""#,
|
||||
r#""domain":"worker.pending_activations.v1""#,
|
||||
r#""next_activation_sequence":2"#,
|
||||
r#""submission_request_id":"completed-submission-request""#,
|
||||
r#""notification_request_id":"completed-notification-request""#,
|
||||
];
|
||||
for location in locations {
|
||||
assert!(original.contains(location));
|
||||
for extra in [
|
||||
r#", "reasoning":{"text":"synthetic-discarded-material"}"#,
|
||||
r#", "encrypted_content":"synthetic-discarded-material""#,
|
||||
] {
|
||||
let altered = original.replacen(location, &format!("{location}{extra}"), 1);
|
||||
assert_rejected_session_bytes(&worker, &template, altered.into_bytes());
|
||||
}
|
||||
}
|
||||
let duplicate = original.replacen(
|
||||
r#""role":"assistant""#,
|
||||
r#""role":"assistant","role":"assistant""#,
|
||||
1,
|
||||
);
|
||||
assert_rejected_session_bytes(&worker, &template, duplicate.into_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_feature_payloads_cannot_bypass_strict_session_validation() {
|
||||
let (_source, worker, template) = source();
|
||||
let mut logs = synthetic_logs(&template);
|
||||
for log in &mut logs {
|
||||
if let LogEntry::AnnotatedUserInput { history, .. } = log {
|
||||
if let session_store::LoggedItem::Message { role, .. } = &mut history[0].item {
|
||||
*role = session_store::LoggedRole::System;
|
||||
}
|
||||
history[0].metadata.input_metadata = Some(
|
||||
session_store::HistoryInputMetadata::new(
|
||||
session_store::HistoryInputUsePolicy {
|
||||
recording_material: session_store::InputEvidenceUse::Exclude,
|
||||
memory_evidence: session_store::InputEvidenceUse::Exclude,
|
||||
observation: session_store::InputObservationUse::Hidden,
|
||||
compaction: session_store::InputCompactionUse::Retain,
|
||||
},
|
||||
Some(
|
||||
session_store::FeatureInputPayload::encode(
|
||||
"fixture.test",
|
||||
1,
|
||||
&BTreeMap::from([("reasoning", "harmless synthetic payload")]),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_rejected_session_bytes(&worker, &template, encode_synthetic_logs(&logs));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_source_and_destination_symlinks() {
|
||||
let (_source, worker, template) = source();
|
||||
let (_target, options) = destination(&template);
|
||||
std::os::unix::fs::symlink(worker.join(WORKER_FILE), options.root.join("linked")).unwrap();
|
||||
assert!(template.install(&options, request(), &resolved()).is_err());
|
||||
fs::remove_file(options.root.join("linked")).unwrap();
|
||||
std::os::unix::fs::symlink(worker.join(WORKER_FILE), worker.join("runs/link")).unwrap();
|
||||
assert!(StoppedWorkerTemplate::extract(&worker).is_err());
|
||||
}
|
||||
|
||||
/// Opt-in only: source path supplied explicitly; assertions never display content.
|
||||
#[test]
|
||||
#[ignore = "requires the approved artificial lab aggregate via YOI_STOPPED_FIXTURE_SOURCE"]
|
||||
fn approved_lab_extract_install() {
|
||||
let path = PathBuf::from(
|
||||
std::env::var_os("YOI_STOPPED_FIXTURE_SOURCE").expect("explicit source required"),
|
||||
);
|
||||
let template = StoppedWorkerTemplate::extract(&path).unwrap();
|
||||
let (_target, options) = destination(&template);
|
||||
let mut bind = request();
|
||||
bind.worker_id = template.worker_id();
|
||||
bind.workspace_api.as_mut().unwrap().workspace_id = template.workspace_id().into();
|
||||
bind.memory_settings.as_mut().unwrap().workspace_id = template.workspace_id().into();
|
||||
let mut manifest = resolved();
|
||||
manifest.worker.name = template.worker_name();
|
||||
template.install(&options, bind, &manifest).unwrap();
|
||||
let installed = options
|
||||
.root
|
||||
.join(WORKERS_DIR)
|
||||
.join(template.worker_id().to_string());
|
||||
for relative in [
|
||||
"session/session.json".to_string(),
|
||||
format!("session/segments/{}.jsonl", template.segment_id()),
|
||||
] {
|
||||
assert!(
|
||||
fs::read(path.join(&relative)).unwrap() == fs::read(installed.join(relative)).unwrap()
|
||||
);
|
||||
}
|
||||
let runtime = crate::Runtime::with_fs_store(options).unwrap();
|
||||
assert_eq!(runtime.list_workers().unwrap().len(), 1);
|
||||
assert_eq!(runtime.summary().unwrap().active_worker_count, 0);
|
||||
}
|
||||
@@ -1389,6 +1389,15 @@ fn bind_workspace_memory_settings(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read-only validation shared with the offline, feature-gated fixture installer.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub(crate) fn validate_fixture_restore_metadata(
|
||||
metadata: &session_store::WorkerMetadata,
|
||||
request: &CreateWorkerRequest,
|
||||
) -> Result<(), String> {
|
||||
ProfileRuntimeWorkerFactory::manifest_for_restore(metadata, request).map(|_| ())
|
||||
}
|
||||
|
||||
fn validate_worker_memory_settings(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
request: &CreateWorkerRequest,
|
||||
|
||||
@@ -71,9 +71,10 @@ pub use session_history::{
|
||||
};
|
||||
pub use shared_state::WorkerSharedState;
|
||||
pub use worker::{
|
||||
LocalWorkingDirectory, SubjektivSessionAttributionLifecycle, Worker, WorkerError,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceBinaryRequest,
|
||||
WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError,
|
||||
LocalWorkingDirectory, ReviewerChildWorkspaceClient, ReviewerContext,
|
||||
SubjektivSessionAttributionLifecycle, Worker, WorkerError, WorkerFilesystemAuthority,
|
||||
WorkerRunResult, WorkerWorkspaceContext, WorkspaceBinaryRequest, WorkspaceBinaryResponse,
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError,
|
||||
WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, apply_worker_manifest,
|
||||
marker_workspace_client, unavailable_workspace_client,
|
||||
|
||||
@@ -834,7 +834,10 @@ mod tests {
|
||||
assert!(common.contains("target-only movement preserves approval"));
|
||||
assert!(reviewer.contains("complete linked MR/result snapshot"));
|
||||
assert!(reviewer.contains("Submit `ReviewMergeRequest` once"));
|
||||
assert!(reviewer.contains("without modifying implementation files"));
|
||||
assert!(reviewer.contains("Bash"));
|
||||
assert!(reviewer.contains("Git"));
|
||||
assert!(!reviewer.contains("without modifying"));
|
||||
assert!(!reviewer.contains("read-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -70,7 +70,8 @@ struct SubWorkerSpawnInput {
|
||||
/// Explicitly grant command execution through the parent-owned Workdir tool broker.
|
||||
#[serde(default)]
|
||||
command: bool,
|
||||
/// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate.
|
||||
/// Binds an actual builtin Reviewer child to the current Merge Request candidate.
|
||||
/// Bash may be enabled for Git and other commands.
|
||||
/// Review capability material is generated by the trusted spawn layer.
|
||||
#[serde(default)]
|
||||
review: Option<ReviewerHandoffInput>,
|
||||
@@ -1174,6 +1175,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_handoff_schema_allows_bash_for_git_commands() {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SubWorkerSpawnInput)).unwrap();
|
||||
let description = schema["properties"]["review"]["description"]
|
||||
.as_str()
|
||||
.expect("review handoff description");
|
||||
assert!(description.contains("Bash"));
|
||||
assert!(description.contains("Git"));
|
||||
assert!(!description.contains("read-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_handoff_requires_explicit_builtin_profile_and_writable_scope() {
|
||||
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||
|
||||
@@ -1332,15 +1332,28 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
||||
mut request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id().unwrap_or_default();
|
||||
let ticket_id = &self.context.ticket_id;
|
||||
let ticket_query_path = format!("/api/w/{workspace_id}/tickets/query");
|
||||
let ticket_show_path = format!("/api/w/{workspace_id}/tickets/{ticket_id}/show");
|
||||
let ticket_show_prefix = format!("/api/w/{workspace_id}/tickets/");
|
||||
// ShowTicket uses POST for bounded read parameters. Like Ticket query
|
||||
// and GET reads, it must allow related Tickets through the bound Host's
|
||||
// read authority. Only review submission is restricted to the attested MR.
|
||||
// Match one literal reference segment, not arbitrary POST routes.
|
||||
let is_ticket_show = request
|
||||
.path
|
||||
.strip_prefix(&ticket_show_prefix)
|
||||
.and_then(|path| path.strip_suffix("/show"))
|
||||
.is_some_and(|reference| {
|
||||
!reference.is_empty()
|
||||
&& reference
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
});
|
||||
let merge_request_id = &self.context.merge_request_id;
|
||||
let review_path =
|
||||
format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}/reviews");
|
||||
let read_allowed = request.method == WorkspaceRequestMethod::Get
|
||||
|| (request.method == WorkspaceRequestMethod::Post
|
||||
&& (request.path == ticket_query_path || request.path == ticket_show_path));
|
||||
&& (request.path == ticket_query_path || is_ticket_show));
|
||||
if request.method == WorkspaceRequestMethod::Post && request.path == review_path {
|
||||
let body = request.body.take().ok_or_else(|| {
|
||||
WorkspaceClientError::Request("review submission requires a JSON body".to_string())
|
||||
@@ -1868,16 +1881,11 @@ mod reviewer_client_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_child_client_rejects_other_ticket_and_mutation_posts() {
|
||||
fn reviewer_child_client_rejects_mutation_posts() {
|
||||
let inner: Arc<dyn WorkspaceClient> = Arc::new(RecordingWorkspaceClient::default());
|
||||
let client = reviewer_client(inner);
|
||||
|
||||
for request in [
|
||||
WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/ws/tickets/T2/show",
|
||||
"{}".to_string(),
|
||||
),
|
||||
WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/ws/tickets/T1/comments",
|
||||
@@ -1894,6 +1902,54 @@ mod reviewer_client_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_ticket_reads_reject_route_injection_and_mutations_without_transport() {
|
||||
let inner = Arc::new(RecordingWorkspaceClient::default());
|
||||
let client = reviewer_client(inner.clone());
|
||||
for path in [
|
||||
"/api/w/other/tickets/T-1/show",
|
||||
"/api/w/ws/tickets//show",
|
||||
"/api/w/ws/tickets/T-1/comments",
|
||||
"/api/w/ws/tickets/T-1/posts",
|
||||
"/api/w/ws/tickets/T-1/show/",
|
||||
"/api/w/ws/tickets/T-1/show?query=1",
|
||||
"/api/w/ws/tickets/T-1?query=1/show",
|
||||
"/api/w/ws/tickets/T-1#fragment/show",
|
||||
"/api/w/ws/tickets/../show",
|
||||
"/api/w/ws/tickets/T-1/../T-2/show",
|
||||
"/api/w/ws/tickets/T%2D1/show",
|
||||
"/api/w/ws/tickets/T-1%2fcomments/show",
|
||||
"/api/w/ws/merge-requests/MR2/reviews",
|
||||
] {
|
||||
assert!(
|
||||
client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
path,
|
||||
"{}",
|
||||
))
|
||||
.is_err(),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
for method in [
|
||||
WorkspaceRequestMethod::Put,
|
||||
WorkspaceRequestMethod::Patch,
|
||||
WorkspaceRequestMethod::Delete,
|
||||
] {
|
||||
assert!(
|
||||
client
|
||||
.execute(WorkspaceRequest::json(
|
||||
method,
|
||||
"/api/w/ws/tickets/T-1/show",
|
||||
"{}"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(inner.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_child_client_injects_capability_only_for_attested_review() {
|
||||
let inner = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
@@ -10,6 +10,10 @@ autobins = false
|
||||
name = "yoi-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
# Offline fixture preparation only; build product binaries without this feature.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
api-macros = { workspace = true, features = ["axum"] }
|
||||
async-trait.workspace = true
|
||||
|
||||
@@ -476,7 +476,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
||||
(99, "Explicit hosting administrators and self-registration policy".into()),
|
||||
(100, "Provisional users and scoped passkey activation".into()),
|
||||
(101, "Durable explicit Subject body release".into()),
|
||||
(102, "Durable failed Subject creation recovery".into())
|
||||
(102, "Durable failed Subject creation recovery".into()),
|
||||
(103, "Passkey metadata and atomic addition results".into())
|
||||
]
|
||||
);
|
||||
for (table, rows) in retained.iter().zip(original.iter()) {
|
||||
|
||||
@@ -228,13 +228,21 @@ fn resolve_whoami_sync(
|
||||
use server_api::{BrowserSessionState, WhoamiResponse};
|
||||
let anonymous = || WhoamiResponse {
|
||||
actor: None,
|
||||
authentication_reference: None,
|
||||
session: BrowserSessionState::Anonymous {},
|
||||
};
|
||||
// Preserve the existing API-token precedence and fallback rules, including expired tokens.
|
||||
if let Some((actor, expires_at)) =
|
||||
resolve_credentials_sync(store, bearer, session_token.clone())?
|
||||
resolve_credentials_sync(store, bearer.clone(), session_token.clone())?
|
||||
{
|
||||
let credential = match actor.auth_method {
|
||||
ActorAuthMethod::ApiToken => bearer.as_deref(),
|
||||
ActorAuthMethod::BrowserSession => session_token.as_deref(),
|
||||
}
|
||||
.ok_or_else(|| auth_error("auth_required", "authentication binding missing"))?;
|
||||
let authentication_reference = Some(authentication_reference(&token_hash(credential)));
|
||||
return Ok(WhoamiResponse {
|
||||
authentication_reference,
|
||||
actor: Some(actor),
|
||||
session: BrowserSessionState::Authenticated { expires_at },
|
||||
});
|
||||
@@ -255,6 +263,7 @@ fn resolve_whoami_sync(
|
||||
if within_session_expiry_grace(&session.expires_at, Utc::now()) {
|
||||
return Ok(WhoamiResponse {
|
||||
actor: None,
|
||||
authentication_reference: None,
|
||||
session: BrowserSessionState::Expired {},
|
||||
});
|
||||
}
|
||||
@@ -262,6 +271,13 @@ fn resolve_whoami_sync(
|
||||
Ok(anonymous())
|
||||
}
|
||||
|
||||
/// Domain-separated public comparison value; never accepted as a credential.
|
||||
pub(crate) fn authentication_reference(credential_hash: &str) -> String {
|
||||
token_hash(&format!(
|
||||
"passkey-authentication-reference:{credential_hash}"
|
||||
))
|
||||
}
|
||||
|
||||
fn within_session_expiry_grace(expires_at: &str, now: DateTime<Utc>) -> bool {
|
||||
DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| {
|
||||
expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS)
|
||||
|
||||
@@ -8866,7 +8866,14 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.expect_err("closed session must reject local operation without another request");
|
||||
assert!(matches!(error, WorkdirError::Unavailable(_)));
|
||||
assert!(matches!(
|
||||
error.classification_source(),
|
||||
WorkdirError::Unavailable(_)
|
||||
));
|
||||
assert_eq!(
|
||||
error.failure_reason(),
|
||||
Some(workdir::WorkdirFailureReason::SessionClosed)
|
||||
);
|
||||
server.join().expect("mock remote server finished");
|
||||
}
|
||||
|
||||
|
||||
@@ -381,6 +381,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
|
||||
DROP TABLE ticket_implementation_results;
|
||||
DROP TABLE ticket_implementation_delegations;
|
||||
DROP TABLE merge_request_source_observations;
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
|
||||
DROP TABLE subject_create_recoveries;
|
||||
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
|
||||
DROP TABLE subject_worker_releases;
|
||||
@@ -517,6 +521,10 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
|
||||
// Reconstruct the prior composition before its new business capture existed.
|
||||
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
|
||||
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
|
||||
DROP TABLE subject_create_recoveries;
|
||||
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
|
||||
DROP TABLE subject_worker_releases;
|
||||
@@ -679,7 +687,11 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
|
||||
if historical {
|
||||
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
|
||||
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
|
||||
DROP TABLE subject_create_recoveries;
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
|
||||
DROP TABLE subject_create_recoveries;
|
||||
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
|
||||
DROP TABLE subject_worker_releases;
|
||||
UPDATE __yoi_schema_migrations SET version=100 WHERE version=101;
|
||||
|
||||
@@ -25,6 +25,8 @@ pub mod runtime_subscription;
|
||||
pub mod server;
|
||||
pub mod skills;
|
||||
pub mod store;
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use store::fixture_support as test_support;
|
||||
pub mod subjektiv;
|
||||
#[cfg(test)]
|
||||
mod ticket_browse_performance_tests;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE passkey_metadata (
|
||||
credential_id TEXT PRIMARY KEY REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE,
|
||||
key_reference TEXT NOT NULL UNIQUE,
|
||||
normal_login_verified_at TEXT
|
||||
);
|
||||
INSERT INTO passkey_metadata(credential_id, key_reference)
|
||||
SELECT credential_id, lower(hex(randomblob(16))) FROM passkey_credentials;
|
||||
CREATE TRIGGER passkey_metadata_insert AFTER INSERT ON passkey_credentials
|
||||
BEGIN
|
||||
INSERT INTO passkey_metadata(credential_id, key_reference)
|
||||
VALUES (NEW.credential_id, lower(hex(randomblob(16))));
|
||||
END;
|
||||
CREATE TABLE passkey_addition_receipts (
|
||||
challenge_id TEXT PRIMARY KEY,
|
||||
credential_id TEXT NOT NULL UNIQUE REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
authentication_reference TEXT NOT NULL
|
||||
);
|
||||
@@ -6970,6 +6970,12 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
|
||||
.merge(server_api::server_api_axum::hosting_settings_update(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_passkey_list(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_passkey_addition_result(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_passkey_addition_options(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -8179,6 +8185,65 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
)
|
||||
}
|
||||
|
||||
async fn auth_passkey_list(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
) -> std::result::Result<server_api::PasskeyListResponse, server_api::RepositoryApiError> {
|
||||
let headers = contract_request_headers(&context)?;
|
||||
let api = self.auth_api();
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
let (user, _) = passkey_owner_authority(&api, &headers, false)?;
|
||||
Ok::<_, ApiError>(server_api::PasskeyListResponse {
|
||||
keys: api.store.list_passkey_summaries(&user.user_id)?,
|
||||
user: user_response(user),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn auth_passkey_addition_result(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
request: server_api::PasskeyAdditionResultRequest,
|
||||
) -> std::result::Result<
|
||||
server_api::PasskeyAdditionResultResponse,
|
||||
server_api::RepositoryApiError,
|
||||
> {
|
||||
let headers = contract_request_headers(&context)?;
|
||||
let api = self.auth_api();
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
let (user, _) = passkey_addition_authority(&api, &headers)?;
|
||||
if user.user_id != request.expected_user_id
|
||||
|| user.account_id != request.expected_account_id
|
||||
{
|
||||
return Err(auth_error(
|
||||
"passkey_authentication_changed",
|
||||
"current authenticated owner does not match the operation owner",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let key = api.store.read_passkey_addition_result(
|
||||
&user.user_id,
|
||||
&request.expected_authentication_reference,
|
||||
&request.challenge_id,
|
||||
)?;
|
||||
Ok::<_, ApiError>(match key {
|
||||
Some(key) => server_api::PasskeyAdditionResultResponse::Saved {
|
||||
user: user_response(user),
|
||||
key,
|
||||
},
|
||||
None => server_api::PasskeyAdditionResultResponse::Unknown {},
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn auth_passkey_addition_options(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
@@ -8191,6 +8256,13 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
let (user, authentication) = passkey_addition_authority(&api, &headers)?;
|
||||
require_expected_passkey_owner(
|
||||
&user,
|
||||
&authentication,
|
||||
&request.expected_user_id,
|
||||
&request.expected_account_id,
|
||||
&request.expected_authentication_reference,
|
||||
)?;
|
||||
start_passkey_registration(
|
||||
&api,
|
||||
&headers,
|
||||
@@ -30161,6 +30233,14 @@ async fn post_passkey_registration_options(
|
||||
fn passkey_addition_authority(
|
||||
api: &ServerAuthApi,
|
||||
headers: &HeaderMap,
|
||||
) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
|
||||
passkey_owner_authority(api, headers, true)
|
||||
}
|
||||
|
||||
fn passkey_owner_authority(
|
||||
api: &ServerAuthApi,
|
||||
headers: &HeaderMap,
|
||||
require_origin: bool,
|
||||
) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
|
||||
let auth = auth_public_config(&api.config);
|
||||
let actor = crate::auth::resolve_request_actor_sync(
|
||||
@@ -30176,7 +30256,7 @@ fn passkey_addition_authority(
|
||||
})?;
|
||||
let binding = match actor.auth_method {
|
||||
ActorAuthMethod::BrowserSession => {
|
||||
if request_origin(headers).as_deref() != Some(auth.origin.as_str()) {
|
||||
if require_origin && request_origin(headers).as_deref() != Some(auth.origin.as_str()) {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"cookie-authenticated passkey addition requires the configured Browser origin"
|
||||
.into(),
|
||||
@@ -30208,6 +30288,26 @@ fn passkey_addition_authority(
|
||||
Ok((user, binding))
|
||||
}
|
||||
|
||||
fn require_expected_passkey_owner(
|
||||
user: &UserRecord,
|
||||
binding: &PasskeyAuthenticationBinding,
|
||||
expected_user_id: &str,
|
||||
expected_account_id: &str,
|
||||
expected_authentication_reference: &str,
|
||||
) -> ApiResult<()> {
|
||||
if user.user_id != expected_user_id
|
||||
|| user.account_id != expected_account_id
|
||||
|| binding.reference() != expected_authentication_reference
|
||||
{
|
||||
return Err(auth_error(
|
||||
"passkey_authentication_changed",
|
||||
"authentication changed; refresh account before starting another operation",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_passkey_registration(
|
||||
api: &ServerAuthApi,
|
||||
headers: &HeaderMap,
|
||||
@@ -30289,7 +30389,11 @@ fn start_passkey_registration(
|
||||
/// Credential commit precedes session issuance. If issuing the session or
|
||||
/// delivering the response fails, the credential can already exist: log in with
|
||||
/// that key rather than overwriting it or replaying the consumed completion.
|
||||
/// After an unknown commit outcome, try login first. Restart new-account
|
||||
/// Own additions save a receipt atomically with the credential: a currently
|
||||
/// authenticated same owner can inspect it using the original operation binding
|
||||
/// reference even if the response body was lost after Set-Cookie took effect.
|
||||
/// This read does not authorize a completion or prove current session health.
|
||||
/// For unknown new-account outcomes, try login first. Restart new-account
|
||||
/// enrollment with fresh options only for an unused handle with no persisted
|
||||
/// identity; legacy pre-created identities follow the migration policy on
|
||||
/// [`PasskeyRegistrationEnvelope`]. Neither recovery path requires DB edits.
|
||||
@@ -30313,6 +30417,7 @@ fn complete_passkey_enrollment(
|
||||
request: PasskeyRegistrationCompleteRequest,
|
||||
completion: EnrollmentCompletion<'_>,
|
||||
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
|
||||
let is_addition = matches!(&completion, EnrollmentCompletion::Addition(_));
|
||||
let authority = match completion {
|
||||
EnrollmentCompletion::Addition(headers) => Some(passkey_addition_authority(api, headers)?),
|
||||
_ => None,
|
||||
@@ -30398,7 +30503,7 @@ fn complete_passkey_enrollment(
|
||||
&error.to_string(),
|
||||
)
|
||||
})?;
|
||||
api.store.register_verified_passkey(
|
||||
api.store.register_verified_passkey_with_result(
|
||||
&envelope.target,
|
||||
&PasskeyCredentialRecord {
|
||||
credential_id: passkey_credential_id(&passkey)?,
|
||||
@@ -30411,6 +30516,7 @@ fn complete_passkey_enrollment(
|
||||
created_at: crate::auth::now_rfc3339(),
|
||||
last_used_at: None,
|
||||
},
|
||||
is_addition.then_some(request.challenge_id.as_str()),
|
||||
)?;
|
||||
issue_browser_session(api, user)
|
||||
}
|
||||
@@ -30580,6 +30686,7 @@ fn complete_passkey_login(
|
||||
.into());
|
||||
}
|
||||
let webauthn = webauthn_for_challenge(&api.config, &challenge)?;
|
||||
let normal_login = matches!(state, PasskeyLoginState::Discoverable(_));
|
||||
let auth_result = match state {
|
||||
PasskeyLoginState::Discoverable(state) => {
|
||||
// userHandle is unsigned routing data, never identity authority. The
|
||||
@@ -30635,7 +30742,7 @@ fn complete_passkey_login(
|
||||
.map_err(|error| auth_error("webauthn_state_serialize_failed", &error.to_string()))?;
|
||||
api.store
|
||||
.upsert_passkey_credential(&PasskeyCredentialRecord {
|
||||
credential_id,
|
||||
credential_id: credential_id.clone(),
|
||||
user_id: user.user_id.clone(),
|
||||
public_key_cose,
|
||||
transports_json: stored.transports_json,
|
||||
@@ -30643,6 +30750,10 @@ fn complete_passkey_login(
|
||||
created_at: stored.created_at,
|
||||
last_used_at: Some(crate::auth::now_rfc3339()),
|
||||
})?;
|
||||
if normal_login {
|
||||
api.store
|
||||
.mark_passkey_normal_login_verified(&credential_id, &crate::auth::now_rfc3339())?;
|
||||
}
|
||||
issue_browser_session(api, user)
|
||||
}
|
||||
|
||||
@@ -30668,6 +30779,9 @@ fn issue_browser_session(
|
||||
Ok((
|
||||
AuthUserResponse {
|
||||
user: user_response(user),
|
||||
authentication_reference: crate::auth::authentication_reference(&token_hash(
|
||||
&session_token,
|
||||
)),
|
||||
},
|
||||
server_api::SetCookieHeader::new(cookie),
|
||||
))
|
||||
@@ -37791,9 +37905,13 @@ mod tests {
|
||||
)))
|
||||
.unwrap();
|
||||
}
|
||||
let error = write
|
||||
.await
|
||||
.unwrap()
|
||||
.expect_err("mismatched or lost mutation response must remain outcome-unknown");
|
||||
assert!(matches!(
|
||||
write.await.unwrap(),
|
||||
Err(workdir::WorkdirError::OutcomeUnknown(_))
|
||||
error.classification_source(),
|
||||
workdir::WorkdirError::OutcomeUnknown(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -52397,6 +52515,7 @@ mod tests {
|
||||
|
||||
include!("server_activation_tests.rs");
|
||||
include!("server_discoverable_tests.rs");
|
||||
include!("server_passkey_results_tests.rs");
|
||||
|
||||
const ORIGIN_URL: &str = "https://workspace.test";
|
||||
type Authenticator = ResidentAuthenticator;
|
||||
@@ -52450,9 +52569,61 @@ mod tests {
|
||||
origin: Option<&str>,
|
||||
body: Value,
|
||||
) -> (StatusCode, HeaderMap, Value) {
|
||||
let mut body = body;
|
||||
if path == "/api/auth/passkeys/addition/options"
|
||||
&& body.get("expected_user_id").is_none()
|
||||
{
|
||||
let expected = self.expected_owner(cookie, bearer).await;
|
||||
for (field, value) in expected.as_object().unwrap() {
|
||||
body[field] = value.clone();
|
||||
}
|
||||
}
|
||||
Self::post_to(self.app.clone(), path, cookie, bearer, origin, body).await
|
||||
}
|
||||
|
||||
async fn get_authenticated(
|
||||
&self,
|
||||
path: &str,
|
||||
cookie: Option<&str>,
|
||||
bearer: Option<&str>,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut request = Request::builder().uri(path);
|
||||
if let Some(cookie) = cookie {
|
||||
request = request.header(axum::http::header::COOKIE, cookie);
|
||||
}
|
||||
if let Some(bearer) = bearer {
|
||||
request = request.header(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {bearer}"),
|
||||
);
|
||||
}
|
||||
let response = self
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
|
||||
let status = response.status();
|
||||
let body = serde_json::from_slice(
|
||||
&to_bytes(response.into_body(), usize::MAX).await.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
(status, body)
|
||||
}
|
||||
|
||||
async fn expected_owner(&self, cookie: Option<&str>, bearer: Option<&str>) -> Value {
|
||||
let (status, whoami) = self
|
||||
.get_authenticated("/api/auth/whoami", cookie, bearer)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
serde_json::json!({
|
||||
"expected_user_id": whoami["actor"]["user_id"].as_str().unwrap_or("absent"),
|
||||
"expected_account_id": whoami["actor"]["account_id"].as_str().unwrap_or("absent"),
|
||||
"expected_authentication_reference": whoami["authentication_reference"].as_str().unwrap_or("absent"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_to(
|
||||
app: Router,
|
||||
path: &str,
|
||||
@@ -52608,6 +52779,14 @@ mod tests {
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let (status, whoami) = self
|
||||
.get_authenticated("/api/auth/whoami", Some(&cookie), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
body["authentication_reference"] == whoami["authentication_reference"],
|
||||
"completion must identify its exact issued session"
|
||||
);
|
||||
(
|
||||
cookie,
|
||||
serde_json::from_value::<AuthUserResponse>(body)
|
||||
@@ -52695,6 +52874,14 @@ mod tests {
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let (status, whoami) = self
|
||||
.get_authenticated("/api/auth/whoami", Some(&cookie), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
body["authentication_reference"] == whoami["authentication_reference"],
|
||||
"login completion must identify its exact issued session"
|
||||
);
|
||||
(
|
||||
cookie,
|
||||
serde_json::from_value::<AuthUserResponse>(body)
|
||||
@@ -53218,6 +53405,9 @@ mod tests {
|
||||
.auth_passkey_addition_options(
|
||||
context,
|
||||
server_api::PasskeyAdditionOptionsRequest {
|
||||
expected_user_id: user.user_id.clone(),
|
||||
expected_account_id: user.account_id.clone(),
|
||||
expected_authentication_reference: "not-authority".into(),
|
||||
browser_origin: Some(ORIGIN_URL.into()),
|
||||
},
|
||||
)
|
||||
@@ -53232,6 +53422,9 @@ mod tests {
|
||||
.auth_passkey_addition_options(
|
||||
context,
|
||||
server_api::PasskeyAdditionOptionsRequest {
|
||||
expected_user_id: user.user_id.clone(),
|
||||
expected_account_id: user.account_id.clone(),
|
||||
expected_authentication_reference: "not-authority".into(),
|
||||
browser_origin: Some(ORIGIN_URL.into()),
|
||||
},
|
||||
)
|
||||
@@ -54237,7 +54430,7 @@ mod tests {
|
||||
.header(axum::http::header::COOKIE, &marker)
|
||||
.header(ORIGIN, &auth.origin)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.body(Body::from(r#"{"expected_user_id":"absent","expected_account_id":"absent","expected_authentication_reference":"absent"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -18,6 +18,57 @@ const EVIDENCE_MR: &str = "ticket-evidence-mr";
|
||||
const EVIDENCE_SOURCE: &str = "immutable-approved-source";
|
||||
const EVIDENCE_TARGET: &str = "immutable-target-result";
|
||||
|
||||
#[tokio::test]
|
||||
async fn integration_preserves_legacy_empty_post_ref_and_exposes_new_ref_with_server_schema() {
|
||||
let fixture = EvidenceApiFixture::new().await;
|
||||
let approval = fixture.approve("integration-post-ref");
|
||||
let connection = Connection::open(&fixture.api.config.database_path).unwrap();
|
||||
let legacy_index: i64 = connection.query_row(
|
||||
"SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![TEST_WORKSPACE_ID, fixture.ticket_id], |row| row.get(0),
|
||||
).unwrap();
|
||||
connection.execute(
|
||||
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
|
||||
VALUES(?1,?2,?3,'','comment','Legacy integration')",
|
||||
params![TEST_WORKSPACE_ID, fixture.ticket_id, legacy_index],
|
||||
).unwrap();
|
||||
|
||||
let merged = fixture.integrate(&approval);
|
||||
assert_eq!(fixture.integrate(&approval), merged);
|
||||
let detail = fixture
|
||||
.backend
|
||||
.show(fixture.ticket_id.clone().into())
|
||||
.unwrap();
|
||||
let legacy = detail
|
||||
.events
|
||||
.iter()
|
||||
.find(|event| event.body.as_str() == "Legacy integration")
|
||||
.unwrap();
|
||||
assert_eq!(legacy.event_ref, "");
|
||||
let integrations = detail
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.attributes.get("merge_request_id").map(String::as_str) == Some(EVIDENCE_MR)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
integrations.len(),
|
||||
1,
|
||||
"completion replay must not duplicate the Ticket post"
|
||||
);
|
||||
assert_eq!(
|
||||
integrations[0].event_ref,
|
||||
format!("{}:{}", fixture.ticket_id, legacy_index + 1)
|
||||
);
|
||||
assert_eq!(
|
||||
integrations[0].attributes["operation_id"],
|
||||
"evidence-integration"
|
||||
);
|
||||
let shown = fixture.show().await;
|
||||
assert_eq!(shown.merge_requests[0].state, "merged");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_browse_does_not_observe_sources_but_authoritative_query_and_show_still_do() {
|
||||
struct CountingObserver(Arc<std::sync::atomic::AtomicUsize>);
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
// Integration boundary: generated HTTP admission -> real WebAuthn verifier ->
|
||||
// SQLite credential/receipt transaction -> real session issuance. Only the
|
||||
// authenticator is synthetic; failures are SQLite triggers, not success fakes.
|
||||
|
||||
impl Fixture {
|
||||
async fn addition_result(&self, cookie: &str, challenge_id: &str) -> (StatusCode, Value) {
|
||||
let mut request = self.expected_owner(Some(cookie), None).await;
|
||||
request["challenge_id"] = challenge_id.into();
|
||||
let (status, _, body) = self
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(cookie),
|
||||
Some(ORIGIN_URL),
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
(status, body)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn addition_receipt_is_atomic_and_recovers_credential_commit_not_session_success() {
|
||||
for failure in [
|
||||
"before_credential",
|
||||
"receipt_insert",
|
||||
"session_insert",
|
||||
"lost_response",
|
||||
"lost_body_accepted_cookie",
|
||||
] {
|
||||
let f = Fixture::new().await;
|
||||
let mut original = authenticator();
|
||||
let (cookie, owner) = f.enroll("registration", None, "owner", &mut original).await;
|
||||
let original_expectation = f.expected_owner(Some(&cookie), None).await;
|
||||
let options = f.options("addition", Some(&cookie), "").await;
|
||||
let challenge_id = options.challenge_id.clone();
|
||||
let mut added = authenticator();
|
||||
let request = Fixture::credential(options, &mut added);
|
||||
let (status, result) = f.addition_result(&cookie, &challenge_id).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result["status"], "unknown");
|
||||
let table = match failure {
|
||||
"before_credential" => Some("passkey_credentials"),
|
||||
"receipt_insert" => Some("passkey_addition_receipts"),
|
||||
"session_insert" => Some("browser_sessions"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(table) = table {
|
||||
f.api.config_store.with_conn(|conn| {
|
||||
conn.execute_batch(&format!("CREATE TRIGGER inject_passkey_failure BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END;"))?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
// Discard the completion body; one variant still accepts Set-Cookie.
|
||||
let (status, headers, _) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
Some(&cookie),
|
||||
Some(ORIGIN_URL),
|
||||
request.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
if matches!(failure, "lost_response" | "lost_body_accepted_cookie") {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
);
|
||||
if !matches!(failure, "lost_response" | "lost_body_accepted_cookie") {
|
||||
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
|
||||
}
|
||||
let saved = matches!(
|
||||
failure,
|
||||
"session_insert" | "lost_response" | "lost_body_accepted_cookie"
|
||||
);
|
||||
if failure == "lost_body_accepted_cookie" {
|
||||
let accepted_cookie = headers[axum::http::header::SET_COOKIE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap();
|
||||
let current = f.expected_owner(Some(accepted_cookie), None).await;
|
||||
assert!(
|
||||
current["expected_authentication_reference"]
|
||||
!= original_expectation["expected_authentication_reference"],
|
||||
"addition issued a distinct session"
|
||||
);
|
||||
let mut recovery = original_expectation.clone();
|
||||
recovery["challenge_id"] = challenge_id.clone().into();
|
||||
let (status, _, result) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(accepted_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
recovery,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result["status"], "saved");
|
||||
assert_eq!(result["user"]["user_id"], owner.user_id);
|
||||
}
|
||||
let (status, result) = f.addition_result(&cookie, &challenge_id).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result["status"], if saved { "saved" } else { "unknown" });
|
||||
assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 });
|
||||
let receipt_count = f
|
||||
.api
|
||||
.config_store
|
||||
.with_conn(|conn| {
|
||||
Ok(conn.query_row(
|
||||
"SELECT COUNT(*) FROM passkey_addition_receipts",
|
||||
[],
|
||||
|row| row.get::<_, u64>(0),
|
||||
)?)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(receipt_count, u64::from(saved));
|
||||
if saved {
|
||||
assert_eq!(result["user"]["user_id"], owner.user_id);
|
||||
let (_, listed) = f
|
||||
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
|
||||
.await;
|
||||
let reference = result["key"]["key_reference"].as_str().unwrap();
|
||||
assert!(
|
||||
listed["keys"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|key| key["key_reference"] == reference)
|
||||
);
|
||||
assert!(result["key"]["normal_login_verified_at"].is_null());
|
||||
}
|
||||
if table.is_some() {
|
||||
f.api
|
||||
.config_store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch("DROP TRIGGER inject_passkey_failure")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
// Consumed completion is not retry authority, even after a transaction failure.
|
||||
let (status, headers, _) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
Some(&cookie),
|
||||
Some(ORIGIN_URL),
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
|
||||
assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 });
|
||||
if saved {
|
||||
assert_eq!(f.discoverable_login(&mut added).await, owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_displayed_owner_or_session_cannot_start_addition_or_read_another_binding_result() {
|
||||
let f = Fixture::new().await;
|
||||
let mut alice_key = authenticator();
|
||||
let (alice_cookie, alice) = f
|
||||
.enroll("registration", None, "alice", &mut alice_key)
|
||||
.await;
|
||||
let (bob_cookie, _) = f
|
||||
.enroll("registration", None, "bob", &mut authenticator())
|
||||
.await;
|
||||
let expected = f.expected_owner(Some(&alice_cookie), None).await;
|
||||
let (replacement_cookie, _) = f.login_session("alice", &mut alice_key).await;
|
||||
let count_challenges = || {
|
||||
f.api
|
||||
.config_store
|
||||
.with_conn(|conn| {
|
||||
Ok(
|
||||
conn.query_row("SELECT COUNT(*) FROM auth_challenges", [], |row| {
|
||||
row.get::<_, u64>(0)
|
||||
})?,
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let before = count_challenges();
|
||||
for cookie in [&bob_cookie, &replacement_cookie] {
|
||||
let (status, _, _) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/options",
|
||||
Some(cookie),
|
||||
Some(ORIGIN_URL),
|
||||
expected.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
count_challenges(),
|
||||
before,
|
||||
"stale options must not persist a ceremony"
|
||||
);
|
||||
}
|
||||
let options = f.options("addition", Some(&alice_cookie), "").await;
|
||||
let challenge_id = options.challenge_id.clone();
|
||||
let request = Fixture::credential(options, &mut authenticator());
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
Some(&alice_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
request
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
f.addition_result(&alice_cookie, &challenge_id).await.1["status"],
|
||||
"saved"
|
||||
);
|
||||
// Current owner authorization is separate from original operation binding.
|
||||
let mut recovery = expected.clone();
|
||||
recovery["challenge_id"] = challenge_id.clone().into();
|
||||
let (status, _, result) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(&replacement_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
recovery.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result["status"], "saved");
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(&bob_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
recovery.clone()
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
None,
|
||||
Some(ORIGIN_URL),
|
||||
recovery.clone()
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
recovery["expected_authentication_reference"] = "changed-original-reference".into();
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(&replacement_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
recovery
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
// Bob's own expected fields cannot discover Alice's operation either.
|
||||
let (status, result) = f.addition_result(&bob_cookie, &challenge_id).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result, serde_json::json!({"status":"unknown"}));
|
||||
assert_eq!(f.credentials(&alice).len(), 2);
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/logout",
|
||||
Some(&alice_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
serde_json::json!({})
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
let mut request = expected;
|
||||
request["challenge_id"] = challenge_id.into();
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(&alice_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
request
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn own_key_list_is_safe_and_only_verified_normal_login_marks_confirmation() {
|
||||
let f = Fixture::new().await;
|
||||
let mut key = authenticator();
|
||||
let (cookie, owner) = f.enroll("registration", None, "owner", &mut key).await;
|
||||
let (other_cookie, _) = f
|
||||
.enroll("registration", None, "other", &mut authenticator())
|
||||
.await;
|
||||
assert_eq!(
|
||||
f.get_authenticated("/api/auth/passkeys", None, None)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let (status, list) = f
|
||||
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(list["user"]["user_id"], owner.user_id);
|
||||
assert_eq!(list["keys"].as_array().unwrap().len(), 1);
|
||||
let summary = &list["keys"][0];
|
||||
assert!(summary["normal_login_verified_at"].is_null());
|
||||
assert!(summary["last_used_at"].is_null());
|
||||
let reference = summary["key_reference"].as_str().unwrap();
|
||||
assert_eq!(reference.len(), 32);
|
||||
assert_ne!(reference, f.credentials(&owner)[0].credential_id);
|
||||
assert_eq!(
|
||||
summary
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
[
|
||||
"key_reference",
|
||||
"created_at",
|
||||
"last_used_at",
|
||||
"normal_login_verified_at"
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
let (_, other) = f
|
||||
.get_authenticated("/api/auth/passkeys", Some(&other_cookie), None)
|
||||
.await;
|
||||
assert_ne!(other["keys"][0]["key_reference"], reference);
|
||||
assert_eq!(f.login("owner", &mut key).await, owner);
|
||||
let (_, legacy) = f
|
||||
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
|
||||
.await;
|
||||
assert!(legacy["keys"][0]["last_used_at"].is_string());
|
||||
assert!(legacy["keys"][0]["normal_login_verified_at"].is_null());
|
||||
// Rejected normal assertion does not produce confirmation metadata.
|
||||
let mut invalid = f.discoverable_request(&mut key).await;
|
||||
invalid.credential.response.signature = vec![0u8; 64].into();
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/login/complete",
|
||||
None,
|
||||
Some(ORIGIN_URL),
|
||||
serde_json::to_value(invalid).unwrap()
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert!(
|
||||
f.api.store.list_passkey_summaries(&owner.user_id).unwrap()[0]
|
||||
.normal_login_verified_at
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(f.discoverable_login(&mut key).await, owner);
|
||||
let (_, confirmed) = f
|
||||
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
|
||||
.await;
|
||||
assert_eq!(confirmed["keys"][0]["key_reference"], reference);
|
||||
assert!(confirmed["keys"][0]["normal_login_verified_at"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_transport_metadata_does_not_discard_verified_registration() {
|
||||
let f = Fixture::new().await;
|
||||
let options = f.options("registration", None, "future-transport").await;
|
||||
let mut key = authenticator();
|
||||
let mut request = Fixture::credential(options, &mut key);
|
||||
request["credential"]["response"]["transports"] =
|
||||
serde_json::json!(["internal", "future-transport", "test"]);
|
||||
let (status, _, body) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/registration/complete",
|
||||
None,
|
||||
Some(ORIGIN_URL),
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let user = serde_json::from_value::<AuthUserResponse>(body)
|
||||
.unwrap()
|
||||
.user;
|
||||
assert_eq!(f.discoverable_login(&mut key).await, user);
|
||||
}
|
||||
@@ -120,6 +120,14 @@ struct TicketSurface {
|
||||
}
|
||||
impl TicketSurface {
|
||||
async fn new(root: &Path, client: Arc<TicketRouterClient>, wip: bool) -> Self {
|
||||
Self::with_access(root, client, wip, TicketFeatureAccess::workflow()).await
|
||||
}
|
||||
async fn with_access(
|
||||
root: &Path,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
wip: bool,
|
||||
access: TicketFeatureAccess,
|
||||
) -> Self {
|
||||
let manifest = worker::WorkerManifest::from_toml("[worker]\nname=\"ticket-tools\"\n[model]\nscheme=\"anthropic\"\nmodel_id=\"unused\"\n[engine]\n[scope]\nallow=[]\n").unwrap();
|
||||
let engine =
|
||||
agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
|
||||
@@ -138,22 +146,18 @@ impl TicketSurface {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let report =
|
||||
host.install_features(worker::feature::FeatureRegistryBuilder::new().with_module(
|
||||
TicketFeature::new(client.clone(), TicketFeatureAccess::workflow()),
|
||||
));
|
||||
let report = host.install_features(
|
||||
worker::feature::FeatureRegistryBuilder::new()
|
||||
.with_module(TicketFeature::new(client.clone(), access)),
|
||||
);
|
||||
assert!(!report.has_errors());
|
||||
let handle = host.engine().tool_server_handle();
|
||||
handle.flush_pending();
|
||||
let tools = [
|
||||
"TicketAssignmentTrackingQuery",
|
||||
"TicketAssignmentAnswer",
|
||||
"TicketImplementationResult",
|
||||
"TicketImplementationResultQuery",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| (name.into(), handle.get_tool(name).unwrap().1))
|
||||
.collect();
|
||||
let tools = access
|
||||
.tool_names()
|
||||
.into_iter()
|
||||
.map(|name| (name.into(), handle.get_tool(name).unwrap().1))
|
||||
.collect();
|
||||
if !wip {
|
||||
return Self { tools, wip };
|
||||
}
|
||||
@@ -162,7 +166,7 @@ impl TicketSurface {
|
||||
worker::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||
&mut mounts,
|
||||
client,
|
||||
TicketFeatureAccess::workflow(),
|
||||
access,
|
||||
None,
|
||||
&route,
|
||||
)
|
||||
@@ -189,8 +193,9 @@ impl TicketSurface {
|
||||
if !self.wip {
|
||||
return execute_tool(self.tools[name].clone(), input, context).await;
|
||||
}
|
||||
let path = format!("/tickets/{}", input["ticket"].as_str().unwrap());
|
||||
input.as_object_mut().unwrap().remove("ticket");
|
||||
let reference_field = if name == "ShowTicket" { "id" } else { "ticket" };
|
||||
let path = format!("/tickets/{}", input[reference_field].as_str().unwrap());
|
||||
input.as_object_mut().unwrap().remove(reference_field);
|
||||
// Observe the real item representation before invoking a mutation. Tracking
|
||||
// reads alone do not establish the Ticket WIP object validator. Explicit
|
||||
// refresh also reconciles stale observations after an unknown result;
|
||||
@@ -201,6 +206,9 @@ impl TicketSurface {
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await?;
|
||||
if name == "ShowTicket" {
|
||||
return execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":"read","arguments":input}), context).await;
|
||||
}
|
||||
execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":"read","arguments":{}}), ToolExecutionContext::direct()).await?;
|
||||
let inspected = execute_tool(
|
||||
self.tools["Inspect"].clone(),
|
||||
@@ -364,6 +372,210 @@ async fn ticket_public_roundtrip_observed_answer_replays_without_changing_waitin
|
||||
}
|
||||
}
|
||||
|
||||
// Reviewer policy, tool projection, reference resolution, authentication and
|
||||
// persistence remain production code. Only the synchronous HTTP transport is
|
||||
// replaced by signed in-process router dispatch. Child spawning and MR attestation
|
||||
// have their own suites; this fixture does not claim to exercise those boundaries.
|
||||
fn reviewer_client(inner: Arc<TicketRouterClient>, bound_ticket: &str) -> Arc<dyn WorkspaceClient> {
|
||||
Arc::new(worker::ReviewerChildWorkspaceClient::new(
|
||||
inner,
|
||||
worker::ReviewerContext {
|
||||
ticket_id: bound_ticket.into(),
|
||||
merge_request_id: "unsubmitted-review-candidate".into(),
|
||||
},
|
||||
"unused-review-capability".into(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reviewer_reads_saved_related_tickets_by_key_and_id_and_pages_their_threads() {
|
||||
for wip in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _runtime, identity, source, bound_id) = tracking_fixture(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut related = ticket::NewTicket::new("Related requirements");
|
||||
related.body = ticket::MarkdownText::new("Requirements from a different Ticket");
|
||||
let related = backend.create(related).unwrap();
|
||||
backend
|
||||
.add_ticket_relation(
|
||||
bound_id.clone().into(),
|
||||
ticket::NewTicketRelation {
|
||||
kind: ticket::TicketRelationKind::DependsOn,
|
||||
target: related.id.clone(),
|
||||
note: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for body in ["Earlier decision", "Latest decision"] {
|
||||
backend
|
||||
.add_event(
|
||||
related.id.clone().into(),
|
||||
ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, body),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let bound = backend.show(bound_id.clone().into()).unwrap();
|
||||
let related_before = backend.show(related.id.clone().into()).unwrap();
|
||||
let bound_key = bound.meta.resource_key.as_deref().unwrap();
|
||||
let related_key = related.resource_key.as_deref().unwrap();
|
||||
let transport = ticket_client(&api, identity, &source);
|
||||
// One client must retain its review binding while following related Tickets.
|
||||
let client = reviewer_client(transport.clone(), &bound_id);
|
||||
let surface =
|
||||
TicketSurface::with_access(dir.path(), client, wip, TicketFeatureAccess::review())
|
||||
.await;
|
||||
for (reference, expected_key, expected_title, expected_body) in [
|
||||
(
|
||||
bound_key,
|
||||
bound_key,
|
||||
"Tracking authority",
|
||||
bound.document.body.as_str(),
|
||||
),
|
||||
(
|
||||
related_key,
|
||||
related_key,
|
||||
"Related requirements",
|
||||
related_before.document.body.as_str(),
|
||||
),
|
||||
(
|
||||
related.id.as_str(),
|
||||
related_key,
|
||||
"Related requirements",
|
||||
related_before.document.body.as_str(),
|
||||
),
|
||||
(
|
||||
bound_id.as_str(),
|
||||
bound_key,
|
||||
"Tracking authority",
|
||||
bound.document.body.as_str(),
|
||||
),
|
||||
] {
|
||||
let shown = surface
|
||||
.call(
|
||||
"ShowTicket",
|
||||
serde_json::json!({"id":reference}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(shown["ticket"], expected_key);
|
||||
assert_eq!(shown["title"], expected_title);
|
||||
assert_eq!(shown["body"], expected_body);
|
||||
if expected_key == bound_key {
|
||||
assert!(
|
||||
shown["relations"]["outgoing"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|relation| relation["ticket"] == related_key
|
||||
&& relation["kind"] == "depends_on")
|
||||
);
|
||||
}
|
||||
}
|
||||
let first = surface
|
||||
.call(
|
||||
"ShowTicket",
|
||||
serde_json::json!({"id":related_key,"event_limit":1}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first["thread"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(first["thread"][0]["body"], "Latest decision");
|
||||
assert_eq!(first["event_page"]["has_more"], true);
|
||||
let second = surface.call("ShowTicket", serde_json::json!({"id":related_key,"event_limit":1,"event_cursor":first["event_page"]["next_cursor"]}), ToolExecutionContext::direct()).await.unwrap();
|
||||
assert_eq!(second["thread"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(second["thread"][0]["body"], "Earlier decision");
|
||||
assert_eq!(backend.show(bound_id.into()).unwrap(), bound);
|
||||
assert_eq!(backend.show(related.id.into()).unwrap(), related_before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reviewer_read_uses_live_backend_membership_and_cannot_mutate_saved_ticket() {
|
||||
for wip in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let before = backend.show(ticket.clone().into()).unwrap();
|
||||
let transport = ticket_client(&api, identity, &source);
|
||||
let client = reviewer_client(transport.clone(), &ticket);
|
||||
let surface = TicketSurface::with_access(
|
||||
dir.path(),
|
||||
client.clone(),
|
||||
wip,
|
||||
TicketFeatureAccess::review(),
|
||||
)
|
||||
.await;
|
||||
surface
|
||||
.call(
|
||||
"ShowTicket",
|
||||
serde_json::json!({"id":ticket}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let request_count = transport.requests.lock().unwrap().len();
|
||||
for (method, path, body) in [
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/comments"),
|
||||
serde_json::json!({"body":"unauthorized comment"}),
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Patch,
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}"),
|
||||
serde_json::json!({"title":"unauthorized title"}),
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/another-workspace/tickets/{ticket}/show"),
|
||||
serde_json::json!({}),
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/merge-requests/other-candidate/reviews"),
|
||||
serde_json::json!({"verdict":"approved"}),
|
||||
),
|
||||
] {
|
||||
let client = client.clone();
|
||||
let error = tokio::task::spawn_blocking(move || {
|
||||
client.execute(WorkspaceRequest::json(method, path, body.to_string()))
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("read-only"), "{error}");
|
||||
}
|
||||
assert_eq!(transport.requests.lock().unwrap().len(), request_count);
|
||||
assert_eq!(backend.show(ticket.clone().into()).unwrap(), before);
|
||||
// Keep a valid Runtime signature but select an unregistered Worker.
|
||||
// This must fail at the real Backend, not the Reviewer route filter.
|
||||
*transport.source.lock().unwrap() =
|
||||
RuntimeWorkerRef::new(source.runtime_id, uuid::Uuid::now_v7().to_string());
|
||||
let error = surface
|
||||
.call(
|
||||
"ShowTicket",
|
||||
serde_json::json!({"id":ticket}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("401"), "{error}");
|
||||
assert!(
|
||||
transport
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.skip(request_count)
|
||||
.any(|(_, status, _)| *status == 401)
|
||||
);
|
||||
assert_eq!(backend.show(ticket.into()).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
fn report_counts(api: &WorkspaceApi) -> (i64, i64) {
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
(
|
||||
|
||||
@@ -119,7 +119,7 @@ pub use ticket::{
|
||||
};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 102;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 103;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -437,6 +437,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Durable failed Subject creation recovery",
|
||||
apply: migrate_subject_create_recovery_v101_to_v102,
|
||||
},
|
||||
Migration {
|
||||
version: 103,
|
||||
name: "Passkey metadata and atomic addition results",
|
||||
apply: migrate_passkey_results_v102_to_v103,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -749,6 +754,15 @@ pub enum PasskeyAuthenticationBinding {
|
||||
ApiToken(String),
|
||||
}
|
||||
|
||||
impl PasskeyAuthenticationBinding {
|
||||
pub(crate) fn reference(&self) -> String {
|
||||
let hash = match self {
|
||||
Self::BrowserSession(hash) | Self::ApiToken(hash) => hash,
|
||||
};
|
||||
crate::auth::authentication_reference(hash)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PasskeyCredentialRecord {
|
||||
pub credential_id: String,
|
||||
@@ -1713,6 +1727,27 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
&self,
|
||||
target: &PasskeyRegistrationTarget,
|
||||
credential: &PasskeyCredentialRecord,
|
||||
) -> Result<()> {
|
||||
self.register_verified_passkey_with_result(target, credential, None)
|
||||
}
|
||||
/// Receipt and credential commit together; no receipt claims session issuance.
|
||||
fn register_verified_passkey_with_result(
|
||||
&self,
|
||||
target: &PasskeyRegistrationTarget,
|
||||
credential: &PasskeyCredentialRecord,
|
||||
addition_challenge_id: Option<&str>,
|
||||
) -> Result<()>;
|
||||
fn list_passkey_summaries(&self, user_id: &str) -> Result<Vec<server_api::PasskeySummary>>;
|
||||
fn read_passkey_addition_result(
|
||||
&self,
|
||||
user_id: &str,
|
||||
original_authentication_reference: &str,
|
||||
challenge_id: &str,
|
||||
) -> Result<Option<server_api::PasskeySummary>>;
|
||||
fn mark_passkey_normal_login_verified(
|
||||
&self,
|
||||
credential_id: &str,
|
||||
verified_at: &str,
|
||||
) -> Result<()>;
|
||||
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>;
|
||||
fn get_passkey_credential(
|
||||
@@ -2589,6 +2624,10 @@ pub struct SqliteWorkspaceStore {
|
||||
db_executor: crate::db_executor::DbExecutor,
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
#[path = "test_support.rs"]
|
||||
pub mod fixture_support;
|
||||
|
||||
impl SqliteWorkspaceStore {
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
@@ -2900,6 +2939,33 @@ impl SqliteWorkspaceStore {
|
||||
request_fingerprint: &str,
|
||||
singleton_key: Option<&str>,
|
||||
current_memory_settings: &WorkspaceMemorySettingsRecord,
|
||||
) -> Result<WorkerCreateReservation> {
|
||||
self.reserve_worker_create_with(
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
allocation_key,
|
||||
request_fingerprint,
|
||||
singleton_key,
|
||||
current_memory_settings,
|
||||
|_| Ok(None),
|
||||
|_, _| Ok(()),
|
||||
)
|
||||
}
|
||||
|
||||
// Keep reservation validation, Memory binding and writes in one transaction. The
|
||||
// nondefault test-support module supplies fixed identity / offline catalog hooks;
|
||||
// ordinary creation always supplies a newly generated ID and no completion hook.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn reserve_worker_create_with(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
allocation_key: &str,
|
||||
request_fingerprint: &str,
|
||||
singleton_key: Option<&str>,
|
||||
current_memory_settings: &WorkspaceMemorySettingsRecord,
|
||||
prepare: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<Option<WorkerId>>,
|
||||
complete: impl FnOnce(&rusqlite::Transaction<'_>, &WorkerCreateReservation) -> Result<()>,
|
||||
) -> Result<WorkerCreateReservation> {
|
||||
if allocation_key.trim().is_empty() || request_fingerprint.trim().is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
@@ -2912,6 +2978,7 @@ impl SqliteWorkspaceStore {
|
||||
validate_workspace_memory_settings_record(current_memory_settings, workspace_id)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let fixed_worker_id = prepare(&tx)?;
|
||||
let existing = tx
|
||||
.query_row(
|
||||
"SELECT worker_id, runtime_id, request_fingerprint, create_fingerprint, \
|
||||
@@ -3094,7 +3161,7 @@ impl SqliteWorkspaceStore {
|
||||
validate_workspace_memory_settings_snapshot(&snapshot, workspace_id)?;
|
||||
let create_fingerprint =
|
||||
bound_worker_create_fingerprint(request_fingerprint, &snapshot);
|
||||
let worker_id = WorkerId::now_v7();
|
||||
let worker_id = fixed_worker_id.unwrap_or_else(WorkerId::now_v7);
|
||||
let worker_id_text = worker_id.to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let singleton_generation = match singleton_owner.as_ref() {
|
||||
@@ -3183,13 +3250,15 @@ impl SqliteWorkspaceStore {
|
||||
));
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(WorkerCreateReservation {
|
||||
let reservation = WorkerCreateReservation {
|
||||
worker_id,
|
||||
create_fingerprint,
|
||||
memory_settings: snapshot,
|
||||
singleton,
|
||||
})
|
||||
};
|
||||
complete(&tx, &reservation)?;
|
||||
tx.commit()?;
|
||||
Ok(reservation)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6938,10 +7007,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn register_verified_passkey(
|
||||
fn register_verified_passkey_with_result(
|
||||
&self,
|
||||
target: &PasskeyRegistrationTarget,
|
||||
credential: &PasskeyCredentialRecord,
|
||||
addition_challenge_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
self.with_immediate_transaction(|tx| {
|
||||
let user = match target {
|
||||
@@ -7008,6 +7078,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at],
|
||||
)?;
|
||||
if let Some(challenge_id) = addition_challenge_id {
|
||||
let PasskeyRegistrationTarget::Addition { user, authentication } = target else {
|
||||
return Err(crate::auth::auth_error("passkey_target_mismatch", "receipt requires an addition"));
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO passkey_addition_receipts(challenge_id, credential_id, user_id, authentication_reference) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![challenge_id, credential.credential_id, user.user_id, authentication.reference()],
|
||||
)?;
|
||||
}
|
||||
if let PasskeyRegistrationTarget::Activation { user, issuance_id } = target {
|
||||
if tx.execute("DELETE FROM user_activations WHERE user_id=?1 AND issuance_id=?2", params![user.user_id, issuance_id])? != 1 {
|
||||
return Err(crate::auth::auth_error("activation_code_invalid", "activation issuance no longer exists"));
|
||||
@@ -7017,6 +7096,57 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn list_passkey_summaries(&self, user_id: &str) -> Result<Vec<server_api::PasskeySummary>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at
|
||||
FROM passkey_credentials c JOIN passkey_metadata m USING(credential_id)
|
||||
WHERE c.user_id = ?1 ORDER BY c.created_at, m.key_reference",
|
||||
)?;
|
||||
let rows = stmt.query_map([user_id], read_passkey_summary)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_passkey_addition_result(
|
||||
&self,
|
||||
user_id: &str,
|
||||
original_authentication_reference: &str,
|
||||
challenge_id: &str,
|
||||
) -> Result<Option<server_api::PasskeySummary>> {
|
||||
self.with_conn(|conn| {
|
||||
// Current authenticated ownership authorizes reading. The original
|
||||
// binding reference identifies the saved operation, not the current
|
||||
// session: Set-Cookie may have arrived even if its body was lost.
|
||||
let result = conn.query_row(
|
||||
"SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at, r.authentication_reference
|
||||
FROM passkey_addition_receipts r
|
||||
JOIN passkey_credentials c USING(credential_id)
|
||||
JOIN passkey_metadata m USING(credential_id)
|
||||
WHERE r.challenge_id = ?1 AND r.user_id = ?2 AND c.user_id = ?2",
|
||||
params![challenge_id, user_id],
|
||||
|row| Ok((read_passkey_summary(row)?, row.get::<_, String>(4)?)),
|
||||
).optional()?;
|
||||
match result {
|
||||
Some((key, reference)) if reference == original_authentication_reference => Ok(Some(key)),
|
||||
Some(_) => Err(crate::auth::auth_error("passkey_result_binding_mismatch", "original operation authentication reference does not match")),
|
||||
None => Ok(None),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_passkey_normal_login_verified(
|
||||
&self,
|
||||
credential_id: &str,
|
||||
verified_at: &str,
|
||||
) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute("UPDATE passkey_metadata SET normal_login_verified_at = ?2 WHERE credential_id = ?1", params![credential_id, verified_at])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
activation::require_active(conn, &record.user_id)?;
|
||||
@@ -11877,6 +12007,15 @@ fn passkey_select_sql(where_clause: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn read_passkey_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result<server_api::PasskeySummary> {
|
||||
Ok(server_api::PasskeySummary {
|
||||
key_reference: row.get(0)?,
|
||||
created_at: row.get(1)?,
|
||||
last_used_at: row.get(2)?,
|
||||
normal_login_verified_at: row.get(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_passkey_credential_record(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<PasskeyCredentialRecord> {
|
||||
@@ -15130,6 +15269,22 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_passkey_results_v102_to_v103(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 102 {
|
||||
return Err(Error::Store(
|
||||
"Passkey results migration requires schema 102".into(),
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(include_str!("passkey_results_schema.sql"))?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(103,?1)",
|
||||
["Passkey metadata and atomic addition results"],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_subject_create_recovery_v101_to_v102(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 101 {
|
||||
return Err(Error::Store(
|
||||
@@ -15221,6 +15376,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
|
||||
conn.execute_batch(include_str!("activation_schema.sql"))?;
|
||||
conn.execute_batch(include_str!("passkey_results_schema.sql"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -18914,6 +19070,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
WorkspaceSchemaMigrationStep {version:100, name:"Provisional users and scoped passkey activation".into()},
|
||||
WorkspaceSchemaMigrationStep {version:101, name:"Durable explicit Subject body release".into()},
|
||||
WorkspaceSchemaMigrationStep {version:102, name:"Durable failed Subject creation recovery".into()},
|
||||
WorkspaceSchemaMigrationStep {version:103, name:"Passkey metadata and atomic addition results".into()},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -19026,6 +19183,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(100, "Provisional users and scoped passkey activation".to_string()),
|
||||
(101, "Durable explicit Subject body release".to_string()),
|
||||
(102, "Durable failed Subject creation recovery".to_string()),
|
||||
(103, "Passkey metadata and atomic addition results".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -21254,6 +21412,83 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passkey_results_migration_preserves_old_keys_unknown_confirmation_and_rolls_back() {
|
||||
for baseline in [50, 102] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let database = dir.path().join("server.db");
|
||||
if baseline == 50 {
|
||||
prepare_schema_v50(&database, None);
|
||||
} else {
|
||||
prepare_retained_schema(&database, baseline);
|
||||
}
|
||||
let conn = Connection::open(&database).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
conn.execute_batch(r#"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
|
||||
VALUES('account-old', 'user', 'old', 'Old', 'created', 'updated');
|
||||
INSERT INTO users(user_id, account_id, handle, display_name, created_at, updated_at)
|
||||
VALUES('user-old', 'account-old', 'old', 'Old', 'created', 'updated');
|
||||
INSERT INTO passkey_credentials(credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at)
|
||||
VALUES('old-key', 'user-old', 'unchanged-key-material', '["internal"]', 7, 'created-key', 'used-key');"#)
|
||||
.unwrap();
|
||||
if baseline == 102 {
|
||||
conn.execute_batch("CREATE TRIGGER interrupt_passkey_migration BEFORE INSERT ON __yoi_schema_migrations
|
||||
WHEN NEW.version=103 BEGIN SELECT RAISE(ABORT, 'synthetic migration interruption'); END;").unwrap();
|
||||
assert!(migrate_passkey_results_v102_to_v103(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 102);
|
||||
assert!(!table_exists(&conn, "passkey_metadata").unwrap());
|
||||
assert!(!table_exists(&conn, "passkey_addition_receipts").unwrap());
|
||||
conn.execute_batch("DROP TRIGGER interrupt_passkey_migration")
|
||||
.unwrap();
|
||||
}
|
||||
drop(conn);
|
||||
let preflight = SqliteWorkspaceStore::migration_plan(&database).unwrap();
|
||||
assert!(preflight.migration_required());
|
||||
// Preflight must not install metadata in the source database.
|
||||
let conn = Connection::open(&database).unwrap();
|
||||
assert!(!table_exists(&conn, "passkey_metadata").unwrap());
|
||||
drop(conn);
|
||||
let migrated = SqliteWorkspaceStore::open_for_test(&database).unwrap();
|
||||
let record = migrated.get_passkey_credential("old-key").unwrap().unwrap();
|
||||
assert_eq!(record.public_key_cose, "unchanged-key-material");
|
||||
assert_eq!(record.sign_count, 7);
|
||||
assert_eq!(record.last_used_at.as_deref(), Some("used-key"));
|
||||
let summary = migrated
|
||||
.list_passkey_summaries("user-old")
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert_eq!(summary.created_at, "created-key");
|
||||
assert_eq!(summary.last_used_at.as_deref(), Some("used-key"));
|
||||
assert!(summary.normal_login_verified_at.is_none());
|
||||
assert_eq!(summary.key_reference.len(), 32);
|
||||
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let schema = |conn: &Connection| -> Result<Vec<(String, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT name, sql FROM sqlite_schema WHERE name IN
|
||||
('passkey_metadata', 'passkey_metadata_insert', 'passkey_addition_receipts') ORDER BY name")?;
|
||||
Ok(stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
};
|
||||
assert_eq!(
|
||||
migrated.with_conn(schema).unwrap(),
|
||||
fresh.with_conn(schema).unwrap()
|
||||
);
|
||||
migrated.with_conn(|conn| {
|
||||
assert!(conn.execute("INSERT INTO passkey_metadata(credential_id,key_reference) VALUES('missing-key','ref')", []).is_err());
|
||||
assert!(conn.execute("INSERT INTO passkey_addition_receipts(challenge_id,credential_id,user_id,authentication_reference)
|
||||
VALUES('operation','missing-key','user-old','{}')", []).is_err());
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, u64>(0))?, 0);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
drop(migrated);
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
|
||||
assert_eq!(
|
||||
reopened.list_passkey_summaries("user-old").unwrap(),
|
||||
vec![summary]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_create_recovery_migration_is_atomic_and_matches_fresh_schema() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -21293,7 +21528,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
drop(conn);
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
|
||||
reopened
|
||||
.with_conn(|c| verify_schema_history(c, 102))
|
||||
.with_conn(|c| verify_schema_history(c, LATEST_SCHEMA_VERSION))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -22232,7 +22467,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(99, "Explicit hosting administrators and self-registration policy".into()),
|
||||
(100, "Provisional users and scoped passkey activation".into()),
|
||||
(101, "Durable explicit Subject body release".into()),
|
||||
(102, "Durable failed Subject creation recovery".into())]);
|
||||
(102, "Durable failed Subject creation recovery".into()),
|
||||
(103, "Passkey metadata and atomic addition results".into())]);
|
||||
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,688 @@
|
||||
//! Offline Backend fixture preparation, available only with `test-support`.
|
||||
//!
|
||||
//! Use a new isolated database and the existing account, Workspace signing-identity /
|
||||
//! `ControlPlaneStore::create_workspace_bootstrap`, and Runtime binding APIs first.
|
||||
//! Generate fresh account credentials and signing keys for each environment; no fixture
|
||||
//! credentials are accepted here. Install Profile configuration through the existing
|
||||
//! config-source mechanisms before starting the product processes.
|
||||
//!
|
||||
//! This module seeds Backend ownership only. It neither writes Runtime storage nor
|
||||
//! launches a Worker, fabricates Runtime observations, changes authorization, or sends
|
||||
//! initial input. The caller must separately install the matching stopped Runtime
|
||||
//! fixture. Build product binaries in a separate Cargo invocation **without** this
|
||||
//! feature (Cargo features unify within an invocation).
|
||||
|
||||
use super::*;
|
||||
use crate::hosts::{WorkerSpawnIntent, WorkerSpawnRequest, worker_spawn_create_fingerprint};
|
||||
use worker_runtime::catalog::ProfileSelector;
|
||||
|
||||
/// One ordinary, attachment-free Worker from an externally validated fixture.
|
||||
///
|
||||
/// IDs must match the Runtime fixture. `request` is the unresolved canonical create
|
||||
/// intent, not a Runtime launch request containing credentials or execution grants.
|
||||
/// The Profile selector must already be installed by the harness. `initial_submit`
|
||||
/// must be empty: saved Session contents belong to the Runtime fixture, not new input.
|
||||
/// Singleton/service, Ticket-assigned and attachment-bearing Workers are deliberately
|
||||
/// unsupported.
|
||||
pub struct FixedWorkerSeed<'a> {
|
||||
pub workspace_id: &'a str,
|
||||
pub runtime_id: &'a str,
|
||||
pub worker_id: WorkerId,
|
||||
pub allocation_key: &'a str,
|
||||
pub request: &'a WorkerSpawnRequest,
|
||||
}
|
||||
|
||||
/// Insert a fixed Worker catalog entry and completed creation reservation atomically.
|
||||
///
|
||||
/// Uses the normal reservation validators, authoritative Memory snapshot, fingerprint
|
||||
/// binding, resource-key allocator and immediate transaction. Even an exact repeat is
|
||||
/// rejected: this is not an upsert/import/recovery API. Existing allocations, Worker
|
||||
/// identities and residual lifecycle/attachment records are never adopted or repaired.
|
||||
/// Returns the creation binding needed to check the corresponding Runtime fixture.
|
||||
pub fn seed_fixed_worker(
|
||||
store: &SqliteWorkspaceStore,
|
||||
seed: FixedWorkerSeed<'_>,
|
||||
) -> Result<WorkerCreateReservation> {
|
||||
validate_identifier("workspace_id", seed.workspace_id)?;
|
||||
validate_identifier("runtime_id", seed.runtime_id)?;
|
||||
let request = seed.request;
|
||||
if !request.initial_submit.is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"fixture Worker cannot contain initial_submit; offline seeding accepts no new input"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !matches!(request.intent, WorkerSpawnIntent::WorkspaceCoding)
|
||||
|| request.singleton_key.is_some()
|
||||
|| request.ticket_assignment.is_some()
|
||||
|| request.resolved_subjektiv_attached
|
||||
|| !request.workdir_attachment_requests.is_empty()
|
||||
|| !request.resolved_workdir_attachment_requests.is_empty()
|
||||
|| !request.resolved_workdir_attachments.is_empty()
|
||||
|| request.resolved_config_bundle.is_some()
|
||||
|| request.resolved_workspace_api.is_some()
|
||||
|| request.resolved_memory_settings.is_some()
|
||||
|| request.resolved_worker_observation_enabled
|
||||
|| !request.resolved_worker_observation_grants.is_empty()
|
||||
|| request.resolved_control_operation.is_some()
|
||||
|| request.resolved_execution_grant.is_some()
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"fixture Worker must be ordinary unresolved create intent without singleton, assignments, attachments or grants".into(),
|
||||
));
|
||||
}
|
||||
// Builtin selectors already carry their canonical `builtin:` prefix.
|
||||
let profile = match &request.profile {
|
||||
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => name,
|
||||
};
|
||||
validate_non_empty("fixture Profile selector", profile)?;
|
||||
let worker_id = seed.worker_id.to_string();
|
||||
let display_name = request
|
||||
.requested_worker_name
|
||||
.as_deref()
|
||||
.unwrap_or(&worker_id);
|
||||
validate_non_empty("fixture Worker display name", display_name)?;
|
||||
let fingerprint = worker_spawn_create_fingerprint(request).map_err(Error::InvalidInput)?;
|
||||
let memory = store.get_workspace_memory_settings(seed.workspace_id)?;
|
||||
store.reserve_worker_create_with(
|
||||
seed.workspace_id,
|
||||
seed.runtime_id,
|
||||
seed.allocation_key,
|
||||
&fingerprint,
|
||||
None,
|
||||
&memory,
|
||||
|tx| {
|
||||
require_seed_target(tx, &seed)?;
|
||||
Ok(Some(seed.worker_id))
|
||||
},
|
||||
|tx, reservation| {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
tx.execute(
|
||||
"INSERT INTO worker_registry (
|
||||
workspace_id, runtime_id, worker_id, display_name, profile,
|
||||
retention_state, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, 'normal', ?6, ?6)",
|
||||
params![seed.workspace_id, seed.runtime_id, worker_id, display_name, profile, now],
|
||||
)?;
|
||||
let changed = tx.execute(
|
||||
"UPDATE worker_create_reservations SET state = 'created', updated_at = ?4
|
||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND state = 'reserved'",
|
||||
params![seed.workspace_id, seed.runtime_id, reservation.worker_id.to_string(), now],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(Error::Store("fixture creation reservation disappeared".into()));
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn require_seed_target(tx: &rusqlite::Transaction<'_>, seed: &FixedWorkerSeed<'_>) -> Result<()> {
|
||||
let active: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspaces w
|
||||
JOIN workspace_runtime_bindings r ON r.workspace_id = w.workspace_id
|
||||
JOIN workspace_signing_identities s ON s.workspace_id = w.workspace_id
|
||||
WHERE w.workspace_id = ?1 AND w.state = 'active'
|
||||
AND r.runtime_id = ?2 AND r.revoked_at IS NULL
|
||||
AND r.authentication_mode = 'workspace_identity' AND s.state = 'active')",
|
||||
params![seed.workspace_id, seed.runtime_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !active {
|
||||
return Err(Error::InvalidInput(
|
||||
"fixture requires an active bootstrapped Workspace and authenticated Runtime binding"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let worker_id = seed.worker_id.to_string();
|
||||
let allocation_exists: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM worker_create_reservations
|
||||
WHERE workspace_id = ?1 AND allocation_key = ?2)",
|
||||
params![seed.workspace_id, seed.allocation_key],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if allocation_exists {
|
||||
return Err(Error::InvalidInput(
|
||||
"fixture Worker allocation already exists".into(),
|
||||
));
|
||||
}
|
||||
// Check by WorkerId, not just the proposed Runtime tuple: orphaned and terminal
|
||||
// ownership is still a collision. These tables are owned by this store schema.
|
||||
for table in [
|
||||
"worker_create_reservations",
|
||||
"worker_registry",
|
||||
"worker_registry_observations",
|
||||
"worker_registry_projection_removals",
|
||||
"worker_removal_operations",
|
||||
"worker_singleton_owners",
|
||||
"worker_workdir_links",
|
||||
"worker_restore_intents",
|
||||
"worker_tombstones",
|
||||
"worker_orphan_diagnostics",
|
||||
"worker_registry_projection_diagnostics",
|
||||
"worker_session_archives",
|
||||
"worker_diagnostics_archives",
|
||||
"ticket_assignment_worker_tombstones",
|
||||
] {
|
||||
let exists: bool = tx.query_row(
|
||||
&format!("SELECT EXISTS(SELECT 1 FROM {table} WHERE worker_id = ?1)"),
|
||||
[&worker_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if exists {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"fixture Worker identity already exists in {table}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
// A resource key without a catalog/reservation must not be silently reused.
|
||||
let mut statement = tx.prepare(
|
||||
"SELECT resource_id FROM workspace_resource_keys WHERE resource_kind = 'worker'",
|
||||
)?;
|
||||
let resources = statement.query_map([], |row| row.get::<_, String>(0))?;
|
||||
for resource in resources {
|
||||
let resource = resource?;
|
||||
let existing = resource
|
||||
.is_ascii()
|
||||
.then(|| parse_worker_resource_id(&resource))
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidInput("unknown fixture Worker resource identity collision".into())
|
||||
})?;
|
||||
if existing.worker_id == worker_id {
|
||||
return Err(Error::InvalidInput(
|
||||
"fixture Worker resource identity already exists".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hosts::{
|
||||
WorkerSpawnAcceptanceRequirement, WorkerSpawnWorkingDirectoryAttachmentRequest,
|
||||
WorkerSpawnWorkingDirectoryRequest,
|
||||
};
|
||||
use crate::workspace_signing_identity::{
|
||||
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService,
|
||||
};
|
||||
|
||||
const WORKSPACE: &str = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c501";
|
||||
const RUNTIME: &str = "fixture-runtime";
|
||||
const WORKER: &str = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c502";
|
||||
|
||||
// Exercises the same public bootstrap APIs the offline harness uses. All account
|
||||
// and key identities are newly generated, even across stores with fixed fixture IDs.
|
||||
fn bootstrap() -> SqliteWorkspaceStore {
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let account = AccountRecord {
|
||||
account_id: format!("fixture-account-{}", Uuid::now_v7()),
|
||||
kind: "user".into(),
|
||||
handle: format!("fixture-{}", Uuid::now_v7()),
|
||||
display_name: "Fixture owner".into(),
|
||||
created_at: "2026-10-24T00:00:00Z".into(),
|
||||
updated_at: "2026-10-24T00:00:00Z".into(),
|
||||
};
|
||||
store.upsert_account(&account).unwrap();
|
||||
let identities = WorkspaceSigningIdentityService::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(InMemoryWorkspaceSigningMaterialStore::default()),
|
||||
);
|
||||
let (identity, operation) = identities
|
||||
.prepare_workspace_creation(
|
||||
"fixture-bootstrap",
|
||||
"fixture-bootstrap",
|
||||
WORKSPACE,
|
||||
&account.account_id,
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.create_workspace_bootstrap(
|
||||
&WorkspaceBootstrapRecord {
|
||||
operation_key: "fixture-bootstrap".into(),
|
||||
request_fingerprint: "fixture-bootstrap".into(),
|
||||
workspace: WorkspaceRecord {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
owner_account_id: account.account_id,
|
||||
display_name: "Fixture".into(),
|
||||
state: "active".into(),
|
||||
created_at: account.created_at,
|
||||
updated_at: account.updated_at,
|
||||
},
|
||||
repository: None,
|
||||
},
|
||||
&identity,
|
||||
&operation,
|
||||
)
|
||||
.unwrap();
|
||||
let runtime_key = worker_runtime::auth::RuntimeIdentityMaterial::generate(RUNTIME).unwrap();
|
||||
store
|
||||
.upsert_workspace_runtime_binding(
|
||||
WorkspaceRuntimeBinding {
|
||||
workspace_id: WORKSPACE.into(),
|
||||
runtime_id: RUNTIME.into(),
|
||||
display_name: "Fixture Runtime".into(),
|
||||
base_url: "http://127.0.0.1:12345".into(),
|
||||
public_key: runtime_key.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: String::new(),
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
created_at: "2026-10-24T00:00:00Z".into(),
|
||||
updated_at: "2026-10-24T00:00:00Z".into(),
|
||||
revoked_at: None,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
}
|
||||
|
||||
fn request() -> WorkerSpawnRequest {
|
||||
WorkerSpawnRequest {
|
||||
intent: WorkerSpawnIntent::WorkspaceCoding,
|
||||
requested_worker_name: Some("Fixture Worker".into()),
|
||||
singleton_key: None,
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
|
||||
profile: ProfileSelector::Builtin("builtin:coder".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachments: Vec::new(),
|
||||
resolved_config_bundle: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn seed(request: &WorkerSpawnRequest) -> FixedWorkerSeed<'_> {
|
||||
FixedWorkerSeed {
|
||||
workspace_id: WORKSPACE,
|
||||
runtime_id: RUNTIME,
|
||||
worker_id: WORKER.parse().unwrap(),
|
||||
allocation_key: "fixture-allocation",
|
||||
request,
|
||||
}
|
||||
}
|
||||
|
||||
fn worker() -> RuntimeWorkerRef {
|
||||
RuntimeWorkerRef::new(RUNTIME, WORKER)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_ids_are_isolated_and_seed_has_no_runtime_observation() {
|
||||
let a = bootstrap();
|
||||
let b = bootstrap();
|
||||
let request = request();
|
||||
let first = seed_fixed_worker(&a, seed(&request)).unwrap();
|
||||
let second = seed_fixed_worker(&b, seed(&request)).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.worker_id.to_string(), WORKER);
|
||||
assert_eq!(first.memory_settings.workspace_id, WORKSPACE);
|
||||
assert_eq!(first.memory_settings.language, "English");
|
||||
assert!(first.singleton.is_none());
|
||||
assert_ne!(
|
||||
a.get_workspace_signing_identity(WORKSPACE)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key,
|
||||
b.get_workspace_signing_identity(WORKSPACE)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key
|
||||
);
|
||||
assert_ne!(
|
||||
a.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key,
|
||||
b.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key
|
||||
);
|
||||
for store in [&a, &b] {
|
||||
assert_eq!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("W-1")
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.resolve_worker_resource_reference(WORKSPACE, "W-1")
|
||||
.unwrap(),
|
||||
Some(worker())
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.has_active_worker_create_reservation(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
);
|
||||
let registry = store
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(registry.profile.as_deref(), Some("builtin:coder"));
|
||||
assert_eq!(registry.display_name, "Fixture Worker");
|
||||
assert_eq!(registry.retention_state, "normal");
|
||||
let projection = store
|
||||
.with_conn(|conn| read_worker_registry_projection(conn, WORKSPACE, &worker()))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(projection.observation.is_none());
|
||||
assert!(projection.job.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_submit_is_refused_without_registration_or_allocation() {
|
||||
let store = bootstrap();
|
||||
let mut request = request();
|
||||
// Even an empty text segment is new input; the vector itself must be empty.
|
||||
for text in ["new input must not be seeded", ""] {
|
||||
request.initial_submit = vec![protocol::Segment::text(text)];
|
||||
let error = seed_fixed_worker(&store, seed(&request)).unwrap_err();
|
||||
assert!(
|
||||
matches!(error, Error::InvalidInput(ref message) if message.contains("initial_submit"))
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.has_active_worker_create_reservation(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
// The rejected attempts must not consume the allocation or resource counter.
|
||||
request.initial_submit.clear();
|
||||
seed_fixed_worker(&store, seed(&request)).unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("W-1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_replay_changed_input_and_cross_runtime_identity_are_refused() {
|
||||
let store = bootstrap();
|
||||
let mut request = request();
|
||||
seed_fixed_worker(&store, seed(&request)).unwrap();
|
||||
let before = store.get_worker_registry(WORKSPACE, &worker()).unwrap();
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
request.requested_worker_name = Some("Must not overwrite".into());
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
let mut new_allocation = seed(&request);
|
||||
new_allocation.allocation_key = "another-allocation";
|
||||
assert!(seed_fixed_worker(&store, new_allocation).is_err());
|
||||
let mut same_allocation = seed(&request);
|
||||
same_allocation.worker_id = WorkerId::now_v7();
|
||||
assert!(seed_fixed_worker(&store, same_allocation).is_err());
|
||||
assert_eq!(
|
||||
store.get_worker_registry(WORKSPACE, &worker()).unwrap(),
|
||||
before
|
||||
);
|
||||
|
||||
let other = bootstrap();
|
||||
let mut record = before.unwrap();
|
||||
record.worker.runtime_id = "different-runtime".into();
|
||||
other.upsert_worker_registry(&record).unwrap();
|
||||
assert!(seed_fixed_worker(&other, seed(&request)).is_err());
|
||||
assert!(
|
||||
other
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_and_orphaned_resource_identities_are_not_adopted() {
|
||||
let store = bootstrap();
|
||||
let request = request();
|
||||
let memory = store.get_workspace_memory_settings(WORKSPACE).unwrap();
|
||||
let ordinary = store
|
||||
.reserve_worker_create(WORKSPACE, RUNTIME, "ordinary", "fingerprint", None, &memory)
|
||||
.unwrap();
|
||||
let mut collision = seed(&request);
|
||||
collision.worker_id = ordinary.worker_id;
|
||||
assert!(seed_fixed_worker(&store, collision).is_err());
|
||||
// Store-level fault setup only: the external harness never writes SQL.
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
allocate_resource_key(
|
||||
conn,
|
||||
WORKSPACE,
|
||||
WorkspaceResourceKind::Worker,
|
||||
&worker_resource_id(&worker()),
|
||||
"now",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_resource_identity_and_retained_removal_fence_are_refused() {
|
||||
let request = request();
|
||||
let malformed = bootstrap();
|
||||
malformed
|
||||
.with_conn(|conn| {
|
||||
allocate_resource_key(
|
||||
conn,
|
||||
WORKSPACE,
|
||||
WorkspaceResourceKind::Worker,
|
||||
"unknown-identity",
|
||||
"now",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
seed_fixed_worker(&malformed, seed(&request)),
|
||||
Err(Error::InvalidInput(_))
|
||||
));
|
||||
assert!(
|
||||
malformed
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let removed = bootstrap();
|
||||
removed.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO worker_registry_projection_removals(workspace_id, runtime_id, worker_id) VALUES (?1, ?2, ?3)",
|
||||
params![WORKSPACE, RUNTIME, WORKER],
|
||||
)?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert!(matches!(
|
||||
seed_fixed_worker(&removed, seed(&request)),
|
||||
Err(Error::InvalidInput(_))
|
||||
));
|
||||
assert!(
|
||||
removed
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_workspace_runtime_and_revoked_binding_are_refused() {
|
||||
let store = bootstrap();
|
||||
let request = request();
|
||||
let mut unknown = seed(&request);
|
||||
unknown.workspace_id = "unknown";
|
||||
assert!(seed_fixed_worker(&store, unknown).is_err());
|
||||
let mut unknown = seed(&request);
|
||||
unknown.runtime_id = "unknown";
|
||||
assert!(seed_fixed_worker(&store, unknown).is_err());
|
||||
store
|
||||
.revoke_workspace_runtime_binding(WORKSPACE, RUNTIME, "2026-10-24T01:00:00Z")
|
||||
.unwrap();
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
assert!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_intent_is_refused_without_partial_registration() {
|
||||
let store = bootstrap();
|
||||
let base = request();
|
||||
let mut requests = Vec::new();
|
||||
let mut request = base.clone();
|
||||
request.singleton_key = Some("fixture-singleton".into());
|
||||
requests.push(request);
|
||||
let mut request = base.clone();
|
||||
request.intent = WorkerSpawnIntent::WorkspaceOrchestrator;
|
||||
requests.push(request);
|
||||
let mut request = base.clone();
|
||||
request.resolved_subjektiv_attached = true;
|
||||
requests.push(request);
|
||||
let mut request = base.clone();
|
||||
request
|
||||
.workdir_attachment_requests
|
||||
.push(WorkerSpawnWorkingDirectoryAttachmentRequest {
|
||||
alias: workdir::WorkdirAttachmentAlias::new("repo").unwrap(),
|
||||
working_directory: WorkerSpawnWorkingDirectoryRequest {
|
||||
repository_key: "repo".into(),
|
||||
selector: None,
|
||||
},
|
||||
});
|
||||
requests.push(request);
|
||||
for request in requests {
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
seed_fixed_worker(&store, seed(&base))
|
||||
.unwrap()
|
||||
.worker_id
|
||||
.to_string(),
|
||||
WORKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_catalog_failure_rolls_back_reservation_and_resource_allocation() {
|
||||
let store = bootstrap();
|
||||
let request = request();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TEMP TRIGGER fail_fixture_catalog BEFORE INSERT ON worker_registry
|
||||
BEGIN SELECT RAISE(ABORT, 'injected catalog failure'); END",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(seed_fixed_worker(&store, seed(&request)).is_err());
|
||||
assert!(
|
||||
!store
|
||||
.has_active_worker_create_reservation(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch("DROP TRIGGER fail_fixture_catalog")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
seed_fixed_worker(&store, seed(&request)).unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.worker_resource_key(WORKSPACE, &worker())
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("W-1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_creation_still_allocates_and_replays_without_catalog_insertion() {
|
||||
let store = bootstrap();
|
||||
let memory = store.get_workspace_memory_settings(WORKSPACE).unwrap();
|
||||
let reserve = |key| {
|
||||
store
|
||||
.reserve_worker_create(WORKSPACE, RUNTIME, key, "fingerprint", None, &memory)
|
||||
.unwrap()
|
||||
};
|
||||
let first = reserve("ordinary-one");
|
||||
assert_eq!(first, reserve("ordinary-one"));
|
||||
let second = reserve("ordinary-two");
|
||||
assert_ne!(first.worker_id, second.worker_id);
|
||||
assert_ne!(first.worker_id.to_string(), WORKER);
|
||||
assert_eq!(
|
||||
first.worker_id.as_uuid().get_version(),
|
||||
Some(uuid::Version::SortRand)
|
||||
);
|
||||
let worker = RuntimeWorkerRef::new(RUNTIME, first.worker_id.to_string());
|
||||
assert!(
|
||||
store
|
||||
.has_active_worker_create_reservation(WORKSPACE, &worker)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry(WORKSPACE, &worker)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.reserve_worker_create(WORKSPACE, RUNTIME, "ordinary-one", "changed", None, &memory)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
+261
-3
@@ -298,12 +298,17 @@
|
||||
"AuthUserResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"authentication_reference": {
|
||||
"description": "Comparison evidence for the exact newly issued session, not a credential.",
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/AuthenticatedUser"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user"
|
||||
"user",
|
||||
"authentication_reference"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -6709,15 +6714,112 @@
|
||||
},
|
||||
"PasskeyAdditionOptionsRequest": {
|
||||
"additionalProperties": false,
|
||||
"description": "Adds a credential to the current authenticated actor; target identity is never public input.",
|
||||
"description": "Adds to the current actor. Expected identity/binding fields only fence stale UI;\nthey never select an owner or authorize an addition.",
|
||||
"properties": {
|
||||
"browser_origin": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"expected_account_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"expected_authentication_reference": {
|
||||
"type": "string"
|
||||
},
|
||||
"expected_user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expected_user_id",
|
||||
"expected_account_id",
|
||||
"expected_authentication_reference"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PasskeyAdditionResultRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"challenge_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"expected_account_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"expected_authentication_reference": {
|
||||
"description": "Original options authentication reference; the current same-owner session may differ.",
|
||||
"type": "string"
|
||||
},
|
||||
"expected_user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"challenge_id",
|
||||
"expected_user_id",
|
||||
"expected_account_id",
|
||||
"expected_authentication_reference"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PasskeyAdditionResultResponse": {
|
||||
"description": "Unknown is not proof that registration failed: completion may still be in flight.\nSaved proves the credential commit, not session issuance or current session health.",
|
||||
"oneOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "unknown",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": {
|
||||
"$ref": "#/components/schemas/PasskeySummary"
|
||||
},
|
||||
"status": {
|
||||
"const": "saved",
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/AuthenticatedUser"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"user",
|
||||
"key"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PasskeyListResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"keys": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PasskeySummary"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/AuthenticatedUser"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user",
|
||||
"keys"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PasskeyLoginCompleteRequest": {
|
||||
@@ -6820,6 +6922,35 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PasskeySummary": {
|
||||
"additionalProperties": false,
|
||||
"description": "Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed.\nNormal-login verification is observed verifier success, not inferred discoverability.",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"key_reference": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_used_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"normal_login_verified_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key_reference",
|
||||
"created_at"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PasteArtifactAvailability": {
|
||||
"enum": [
|
||||
"available",
|
||||
@@ -15143,6 +15274,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"authentication_reference": {
|
||||
"description": "Non-secret comparison evidence, never an authentication credential.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"session": {
|
||||
"$ref": "#/components/schemas/BrowserSessionState"
|
||||
}
|
||||
@@ -18155,7 +18293,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7"
|
||||
"x-yoi-source-digest": "sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -18589,6 +18727,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/passkeys": {
|
||||
"get": {
|
||||
"operationId": "auth_passkey_list",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasskeyListResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/auth/passkeys/addition/complete": {
|
||||
"post": {
|
||||
"operationId": "auth_passkey_addition_complete",
|
||||
@@ -18736,6 +18929,71 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/auth/passkeys/addition/result": {
|
||||
"post": {
|
||||
"operationId": "auth_passkey_addition_result",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasskeyAdditionResultRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasskeyAdditionResultResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/auth/passkeys/login/complete": {
|
||||
"post": {
|
||||
"operationId": "auth_passkey_login_complete",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
You are the independent MR Reviewer, a direct SubWorker of the review requester.
|
||||
|
||||
Evaluate the captured MR subject, Ticket `content_digest`, and complete linked MR/result snapshot against the Ticket requirements. Approval attests that snapshot, not just the selected diff. Inspect and test without modifying implementation files, repository refs, or Ticket state.
|
||||
Evaluate the captured MR subject, Ticket `content_digest`, and complete linked MR/result snapshot against the Ticket requirements. Approval attests that snapshot, not just the selected diff. You may use Bash for Git and other commands.
|
||||
|
||||
Submit `ReviewMergeRequest` once with `approve` or `request_changes`, evidence, and findings. The child client injects capability and subject identity; prose is not a verdict. Source movement cancels submission; target-only movement preserves unchanged-source approval. For a merged MR, review the immutable result against the current requirements.
|
||||
|
||||
@@ -9,6 +9,7 @@ autotests = false
|
||||
[features]
|
||||
default = []
|
||||
e2e = []
|
||||
fixture-support = ["yoi-workspace-server/test-support", "worker-runtime/test-support"]
|
||||
|
||||
[dependencies]
|
||||
libc.workspace = true
|
||||
@@ -17,11 +18,17 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
protocol.workspace = true
|
||||
tungstenite = { version = "0.29", default-features = false, features = ["handshake"] }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
yoi-workspace-server.workspace = true
|
||||
server-api.workspace = true
|
||||
worker-runtime.workspace = true
|
||||
session-store.workspace = true
|
||||
config-source.workspace = true
|
||||
manifest.workspace = true
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
|
||||
|
||||
@@ -44,3 +51,8 @@ required-features = ["e2e"]
|
||||
name = "worker_environment"
|
||||
path = "tests/worker_environment.rs"
|
||||
required-features = ["e2e"]
|
||||
|
||||
[[test]]
|
||||
name = "worker_restore"
|
||||
path = "tests/worker_restore.rs"
|
||||
required-features = ["e2e", "fixture-support"]
|
||||
|
||||
@@ -7,3 +7,42 @@
|
||||
- [`PanelHarness`]: PTY経由の入力、観測イベントによる同期、失敗時のartifact保存。
|
||||
- [`FixtureWorkspace`]: TUI用WorkspaceとHOME/XDG領域の隔離。
|
||||
- [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。
|
||||
- [`FixtureSource`]: 提供資産の全ファイルSHA-256記録、独立copy、原本不変の検証。
|
||||
- [`WorkerSessionSubscription`]: 公開Worker WebSocketの初期full Snapshot照合と停止確認。モデル呼出しは行わない。
|
||||
- `tests/auth_passkeys/native_migration.rs`・`browser/native_passkey_migration.ts`: 実Backend/本番shell/Chromium native CTAP2の旧鍵移行。fixture境界・未確認範囲・明示選択コマンドはmoduleに置く。通常suiteやsoftware authenticatorの成功で代替しない。
|
||||
|
||||
## 固定Workerの段階起動
|
||||
|
||||
1. 提供fixtureを所有crateの型で検証し、`FixtureSource`で原本hashを記録する。各環境の一時領域へcopyする。fixtureが未提供・不適合なら失敗とし、代替Workerを生成しない。
|
||||
2. `initialize_runtime_identity`で固定Runtime IDに新しい鍵を発行する。Backendの型付きstore APIで新しいAccount認証・Workspace署名identity・Runtime binding・固定Worker登録を準備する。
|
||||
3. `start_seeded_backend`へ固定Workspace/Runtime IDとその環境の新しいbearerを渡す。Server起動後の公開APIからWorkspaceの公開identityを取得する。
|
||||
4. `start_runtime_with_seed`はtrust設定後、Runtime processの起動前にseed closureを呼ぶ。ここで実Server URLを使いRuntime所有crateの型付きinstallを呼ぶ。Runtime store rootは`runtime_data_dir.join("runtime")`。
|
||||
5. Worker一覧とdetailから停止状態・観測tokenを確認し、一つの固定request IDで`restore_worker`を一度だけ呼ぶ。結果不明・拒否は成功扱いせず、tokenを自動更新しない。
|
||||
6. `WorkerSessionSubscription::connect`で期待`protocol::SessionSnapshot`全体(entry ID、timestamp、順序、本文を含む)を照合する。二環境を同時に保持し、一方のstop/cleanup後も他方の購読・一覧・内容を再確認する。
|
||||
7. `stop_worker`、購読の`expect_stopped`、環境cleanup、原本`assert_unchanged`を確認する。
|
||||
|
||||
所有crateの`test-support`依存はテスト側の非default `fixture-support` featureだけで有効にする。製品binary providerは別Cargo invocationと専用`target/e2e-products`へ`yoi`・`yoi-server`・`yoi-runtime`を追加featureなしでビルドする。support test自身をこのtargetへビルドしてはならない。`YOI_E2E_BIN`等の明示binary overrideを使用する場合も、呼出し側が同じ分離を保証する。既存TUI用`e2e-test` featureは別の`target/e2e-tui-products`へビルドし、restore製品binaryへ混入しない。
|
||||
|
||||
固定資産の正常restore・並行隔離E2E:
|
||||
|
||||
```sh
|
||||
cargo test -p yoi-e2e --features e2e,fixture-support --test worker_restore
|
||||
# 親から提供された候補の検証(読取専用、原本の自動採用や生成はしない)
|
||||
YOI_E2E_RESTORE_FIXTURE=target/fixture-candidate-v1 \
|
||||
cargo test -p yoi-e2e --features e2e,fixture-support --test worker_restore
|
||||
```
|
||||
|
||||
標準資産は`tests/e2e/fixtures/worker-codex-v1`の`manifest.json`・`worker-template.json`・`expected.json`・`workspace-config.dcdl`。manifestの各asset hash/size、schemaと固定ID、source revision、modelを検証する。Workspace configのimport参照を黙って最新へ置換せず、Gitでsource revisionに対する`resources`と`crates/builtin-source`の一致を確認する(Git履歴がない場合も失敗)。移植したSessionの正規公開projectionを供給された会話行と照合し、その全snapshotを実executionのWebSocketと比較する。欠落fixture・hash不一致・未実行は成功扱いしない。
|
||||
|
||||
通常のharness回帰試験(製品processは起動しない):
|
||||
|
||||
```sh
|
||||
cargo test -p yoi-e2e --lib --no-default-features
|
||||
```
|
||||
|
||||
既存の製品process E2Eは別結果として実行・報告する:
|
||||
|
||||
```sh
|
||||
cargo test -p yoi-e2e --features e2e --test worker_environment
|
||||
cargo test -p yoi-e2e --features e2e --test panel --test rewind
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。
|
||||
// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { extname, join, normalize } from "@std/path";
|
||||
import { serveProductionShell } from "./static_shell.ts";
|
||||
import { type Browser, type BrowserContext, chromium } from "playwright";
|
||||
|
||||
const [backend, origin, build, handle] = Deno.args;
|
||||
@@ -13,82 +13,43 @@ if (!backend || !origin || !build || !handle) {
|
||||
"Expected isolated Backend URL, browser origin, production build, handle",
|
||||
);
|
||||
}
|
||||
await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".woff2": "font/woff2",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
const server = Deno.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(new URL(origin).port),
|
||||
onListen() {},
|
||||
}, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/__e2e/expired-session" && request.method === "POST") {
|
||||
if (request.headers.get("origin") !== origin) {
|
||||
return new Response(null, { status: 403 });
|
||||
const server = await serveProductionShell(
|
||||
backend,
|
||||
origin,
|
||||
build,
|
||||
async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (
|
||||
url.pathname === "/__e2e/expired-session" && request.method === "POST"
|
||||
) {
|
||||
if (request.headers.get("origin") !== origin) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
// Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。
|
||||
const fixture = await bridge("expired-session", {}) as {
|
||||
set_cookies: string[];
|
||||
};
|
||||
const headers = new Headers({ "cache-control": "no-store" });
|
||||
for (const cookie of fixture.set_cookies) {
|
||||
headers.append("set-cookie", cookie);
|
||||
}
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
// Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。
|
||||
const fixture = await bridge("expired-session", {}) as {
|
||||
set_cookies: string[];
|
||||
};
|
||||
const headers = new Headers({ "cache-control": "no-store" });
|
||||
for (const cookie of fixture.set_cookies) {
|
||||
headers.append("set-cookie", cookie);
|
||||
if (
|
||||
url.pathname === "/__e2e/primary-cookie-present" &&
|
||||
request.method === "GET"
|
||||
) {
|
||||
// HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。
|
||||
const present = request.headers.get("cookie")?.split(";").some((pair) =>
|
||||
pair.trim().split("=", 1)[0] === "yoi_workspace_session"
|
||||
) ?? false;
|
||||
return Response.json(present, {
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
if (
|
||||
url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET"
|
||||
) {
|
||||
// HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。
|
||||
const present = request.headers.get("cookie")?.split(";").some((pair) =>
|
||||
pair.trim().split("=", 1)[0] === "yoi_workspace_session"
|
||||
) ?? false;
|
||||
return Response.json(present, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
// OriginとCookieを保持し、Set-Cookieを含む実応答をそのまま返す。
|
||||
return await fetch(new URL(url.pathname + url.search, backend), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: ["GET", "HEAD"].includes(request.method)
|
||||
? undefined
|
||||
: await request.arrayBuffer(),
|
||||
redirect: "manual",
|
||||
});
|
||||
}
|
||||
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
|
||||
if (relative.startsWith("..")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
let file = join(build, relative);
|
||||
try {
|
||||
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
||||
return new Response(await Deno.readFile(file), {
|
||||
headers: {
|
||||
"content-type": mime[extname(file)] ?? "application/octet-stream",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
if (url.pathname.startsWith("/_app/")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
return new Response(await Deno.readFile(join(build, "index.html")), {
|
||||
headers: { "content-type": mime[".html"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。
|
||||
const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream())
|
||||
@@ -313,7 +274,8 @@ try {
|
||||
|
||||
// 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、
|
||||
// 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。
|
||||
const expiredTarget = "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25";
|
||||
const expiredTarget =
|
||||
"/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25";
|
||||
const catalogResponse = () =>
|
||||
page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/workspaces" &&
|
||||
@@ -360,6 +322,7 @@ try {
|
||||
assert(expiredCookieNames.includes(marker.name));
|
||||
assertEquals(await whoami(), {
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
});
|
||||
privateRequests.length = 0;
|
||||
@@ -383,7 +346,11 @@ try {
|
||||
[],
|
||||
"expired reload must gate the real private catalog loader",
|
||||
);
|
||||
assertEquals(await whoami(), { actor: null, session: { kind: "expired" } });
|
||||
assertEquals(await whoami(), {
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
});
|
||||
await page.reload();
|
||||
await at(expiredLogin);
|
||||
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
@@ -416,7 +383,11 @@ try {
|
||||
// authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。
|
||||
await context.clearCookies({ name: session.name });
|
||||
const partial = await whoami();
|
||||
assertEquals(partial, { actor: null, session: { kind: "anonymous" } });
|
||||
assertEquals(partial, {
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
await page.reload();
|
||||
await at("/");
|
||||
await context.clearCookies();
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
/// <reference lib="dom" />
|
||||
// 実Backend + 本番static shell + Chromium native WebAuthn/CTAP2の旧鍵移行matrix。
|
||||
// navigator.credentials、HTTP応答、暗号検証を置換しない。CDPは合成認証器の
|
||||
// 能力/本人確認/タッチだけを制御し、credentialのexport/import/deleteはしない。
|
||||
// 旧鍵fixtureだけは実optionsのuser.idとresident指定を変更してnative createする。
|
||||
// 登録attestationはuser handleを署名しないため、本番verifier/storeで保存できる。
|
||||
// 現行製品の登録動作の再現ではなく、旧random handle保存状態の明示的な合成である。
|
||||
// 実スマホ/OSのprovider選択/物理鍵は対象外。mobileはChromiumのviewportのみ。
|
||||
import { join } from "@std/path";
|
||||
import { type Browser, type CDPSession, chromium, type Page } from "playwright";
|
||||
import { serveProductionShell } from "./static_shell.ts";
|
||||
|
||||
const [backend, origin, build, handlePrefix, artifacts] = Deno.args;
|
||||
if (!backend || !origin || !build || !handlePrefix || !artifacts) {
|
||||
throw new Error(
|
||||
"Expected owned Backend, origin, production build, synthetic handle prefix, artifacts",
|
||||
);
|
||||
}
|
||||
const started = performance.now();
|
||||
const server = await serveProductionShell(backend, origin, build);
|
||||
let browser: Browser | undefined;
|
||||
let phase = "launch";
|
||||
const rows: {
|
||||
resident: boolean;
|
||||
authenticator: string;
|
||||
outcome: string;
|
||||
elapsed_ms: number;
|
||||
}[] = [];
|
||||
class NativeCheckError extends Error {}
|
||||
function check(condition: unknown, diagnostic: string): asserts condition {
|
||||
// 値をdumpするassertEqualsは使わない。例外/HTTP本文/credential/cookieをログに残さない。
|
||||
if (!condition) throw new NativeCheckError(diagnostic);
|
||||
}
|
||||
type User = { user_id: string; account_id: string; handle: string };
|
||||
type Key = {
|
||||
key_reference: string;
|
||||
normal_login_verified_at: string | null;
|
||||
last_used_at: string | null;
|
||||
};
|
||||
async function whoami(page: Page) {
|
||||
const response = await page.request.get(`${origin}/api/auth/whoami`);
|
||||
check(response.status() === 200, "whoami status");
|
||||
return await response.json() as {
|
||||
actor: User | null;
|
||||
authentication_reference: string | null;
|
||||
session: { kind: string };
|
||||
};
|
||||
}
|
||||
async function owner(page: Page, user: User, reference?: string) {
|
||||
const current = await whoami(page);
|
||||
check(
|
||||
current.session.kind === "authenticated" &&
|
||||
current.actor?.user_id === user.user_id &&
|
||||
current.actor.account_id === user.account_id &&
|
||||
current.actor.handle === user.handle,
|
||||
"authenticated owner mismatch",
|
||||
);
|
||||
check(
|
||||
typeof current.authentication_reference === "string",
|
||||
"missing authentication reference",
|
||||
);
|
||||
if (reference) {
|
||||
check(
|
||||
current.authentication_reference === reference,
|
||||
"issued authentication mismatch",
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
async function keys(page: Page, user: User): Promise<Key[]> {
|
||||
const response = await page.request.get(`${origin}/api/auth/passkeys`);
|
||||
check(response.status() === 200, "key list status");
|
||||
const result = await response.json();
|
||||
check(
|
||||
result.user.user_id === user.user_id &&
|
||||
result.user.account_id === user.account_id,
|
||||
"key list owner",
|
||||
);
|
||||
return result.keys;
|
||||
}
|
||||
async function presence(
|
||||
cdp: CDPSession,
|
||||
authenticatorId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
await cdp.send("WebAuthn.setAutomaticPresenceSimulation", {
|
||||
authenticatorId,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
async function authenticator(
|
||||
cdp: CDPSession,
|
||||
resident: boolean,
|
||||
automaticPresenceSimulation = true,
|
||||
) {
|
||||
return (await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
||||
options: {
|
||||
protocol: "ctap2",
|
||||
transport: "usb",
|
||||
hasResidentKey: resident,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation,
|
||||
},
|
||||
})).authenticatorId;
|
||||
}
|
||||
async function account(page: Page) {
|
||||
await page.goto(origin + "/account");
|
||||
await page.getByRole("heading", { name: "Account", exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||
}
|
||||
async function logout(page: Page) {
|
||||
await account(page);
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
||||
await page.waitForURL((url) => url.origin === origin && url.pathname === "/");
|
||||
check(
|
||||
(await whoami(page)).session.kind === "anonymous",
|
||||
"logout must clear authentication",
|
||||
);
|
||||
}
|
||||
async function login(page: Page, user: User, legacy: boolean) {
|
||||
await page.goto(origin + "/login?returnTo=%2Faccount");
|
||||
const button = page.getByRole("button", {
|
||||
name: legacy ? "Log in with older key" : "Log in with passkey",
|
||||
exact: true,
|
||||
});
|
||||
if (legacy) {
|
||||
await page.getByText("Use an older key with account ID", { exact: true })
|
||||
.click();
|
||||
await page.getByLabel("Account ID (handle)", { exact: true }).fill(
|
||||
user.handle,
|
||||
);
|
||||
} else {
|
||||
// 互換入口は閉じたまま。通常loginのrequestにhandle/user/accountが入らないことも検査する。
|
||||
check(
|
||||
!await page.getByLabel("Account ID (handle)", { exact: true })
|
||||
.isVisible(),
|
||||
"normal login asks for ID",
|
||||
);
|
||||
}
|
||||
const options = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/login/options"
|
||||
);
|
||||
const complete = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/login/complete"
|
||||
);
|
||||
await button.click();
|
||||
const optionResponse = await options;
|
||||
check(optionResponse.status() === 200, "login options status");
|
||||
const input = optionResponse.request().postDataJSON();
|
||||
if (!legacy) {
|
||||
check(
|
||||
Object.keys(input).every((key) => key === "browser_origin"),
|
||||
"normal login selected an account",
|
||||
);
|
||||
const wire = await optionResponse.json();
|
||||
check(
|
||||
!wire.public_key.publicKey.allowCredentials?.length,
|
||||
"normal login sends an allow-list",
|
||||
);
|
||||
}
|
||||
const response = await complete;
|
||||
check(response.status() === 200, "native login complete status");
|
||||
const issued = await response.json();
|
||||
check(
|
||||
issued.user.user_id === user.user_id &&
|
||||
issued.user.account_id === user.account_id,
|
||||
"login returned wrong owner",
|
||||
);
|
||||
await page.waitForURL((url) => url.pathname === "/account");
|
||||
await owner(page, user, issued.authentication_reference);
|
||||
}
|
||||
|
||||
const terminate = () => {
|
||||
void (async () => {
|
||||
await browser?.close();
|
||||
await server.shutdown();
|
||||
Deno.exit(143);
|
||||
})();
|
||||
};
|
||||
Deno.addSignalListener("SIGTERM", terminate);
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, timeout: 15_000 });
|
||||
for (const resident of [false, true]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
context.setDefaultTimeout(12_000);
|
||||
// 製品もfixtureも外部通信しない。loopback proxy以外へのbrowser requestは拒否する。
|
||||
await context.route(
|
||||
"**/*",
|
||||
(route) =>
|
||||
new URL(route.request().url()).origin === origin
|
||||
? route.continue()
|
||||
: route.abort(),
|
||||
);
|
||||
let page = await context.newPage();
|
||||
let pageErrors = 0;
|
||||
page.on("pageerror", () => ++pageErrors);
|
||||
let cdp = await context.newCDPSession(page);
|
||||
await cdp.send("WebAuthn.enable", { enableUI: false });
|
||||
// 両行とも認証器はresident key対応にする。residentは旧credentialの作成指定だけを変え、
|
||||
// resident必須の追加が能力不足で拒否されるケースとduplicate exclusionを混同しない。
|
||||
const old = await authenticator(cdp, true);
|
||||
let completionPosts = 0;
|
||||
const completions = () => completionPosts;
|
||||
page.on("request", (r) => {
|
||||
if (
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
||||
) ++completionPosts;
|
||||
});
|
||||
try {
|
||||
phase = `legacy-fixture-resident-${resident}`;
|
||||
await page.goto(origin + "/register");
|
||||
// 生options/credentialはpage内にとどめる。user.id/residency以外はServerの要求を維持する。
|
||||
const fixture = await page.evaluate(async ({ handle, resident }) => {
|
||||
const post = async (path: string, body: unknown) => {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("synthetic legacy fixture HTTP rejected");
|
||||
}
|
||||
return await response.json();
|
||||
};
|
||||
const wire = await post("/api/auth/passkeys/registration/options", {
|
||||
handle,
|
||||
display_name: "Synthetic native migration",
|
||||
browser_origin: location.origin,
|
||||
});
|
||||
const bytes = (value: string) =>
|
||||
Uint8Array.from(
|
||||
atob(value.replaceAll("-", "+").replaceAll("_", "/")),
|
||||
(c) => c.charCodeAt(0),
|
||||
);
|
||||
const options = wire.public_key.publicKey;
|
||||
const random = crypto.getRandomValues(new Uint8Array(32));
|
||||
const stable = bytes(options.user.id);
|
||||
const differs = stable.length !== random.length ||
|
||||
stable.some((byte: number, index: number) => byte !== random[index]);
|
||||
options.challenge = bytes(options.challenge);
|
||||
options.user.id = random;
|
||||
options.excludeCredentials = options.excludeCredentials?.map((
|
||||
item: { id: string },
|
||||
) => ({ ...item, id: bytes(item.id) }));
|
||||
options.authenticatorSelection = {
|
||||
...options.authenticatorSelection,
|
||||
residentKey: resident ? "required" : "discouraged",
|
||||
requireResidentKey: resident,
|
||||
};
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: options,
|
||||
}) as PublicKeyCredential | null;
|
||||
if (
|
||||
!credential ||
|
||||
!(credential.response instanceof AuthenticatorAttestationResponse)
|
||||
) throw new Error("native attestation missing");
|
||||
const encode = (value: ArrayBuffer) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(value))).replaceAll(
|
||||
"+",
|
||||
"-",
|
||||
).replaceAll("/", "_").replaceAll("=", "");
|
||||
const issued = await post("/api/auth/passkeys/registration/complete", {
|
||||
challenge_id: wire.challenge_id,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
rawId: encode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: encode(credential.response.attestationObject),
|
||||
clientDataJSON: encode(credential.response.clientDataJSON),
|
||||
transports: credential.response.getTransports(),
|
||||
},
|
||||
extensions: credential.getClientExtensionResults(),
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: issued.user,
|
||||
authentication_reference: issued.authentication_reference,
|
||||
randomDiffersFromStable: differs,
|
||||
};
|
||||
}, {
|
||||
handle: `${handlePrefix}-${resident ? "resident" : "nonresident"}`,
|
||||
resident,
|
||||
});
|
||||
check(
|
||||
fixture.randomDiffersFromStable,
|
||||
"fixture must use a legacy random handle",
|
||||
);
|
||||
const user: User = fixture.user;
|
||||
await owner(page, user, fixture.authentication_reference);
|
||||
const initial = await keys(page, user);
|
||||
check(
|
||||
initial.length === 1 && initial[0].normal_login_verified_at === null,
|
||||
"legacy key initially unverified",
|
||||
);
|
||||
const oldReference = initial[0].key_reference;
|
||||
await logout(page);
|
||||
phase = `older-key-login-before-resident-${resident}`;
|
||||
await login(page, user, true);
|
||||
|
||||
phase = `same-authenticator-excluded-resident-${resident}`;
|
||||
const sameStarted = performance.now();
|
||||
const add = () =>
|
||||
page.getByRole("button", {
|
||||
name: `Add passkey to ${user.handle}`,
|
||||
exact: true,
|
||||
});
|
||||
const optionsResponse = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
||||
);
|
||||
await add().click();
|
||||
check(
|
||||
(await optionsResponse).status() === 200,
|
||||
"addition options status",
|
||||
);
|
||||
const excluded = page.getByRole("alert").filter({
|
||||
hasText: "[authenticator/excluded]",
|
||||
});
|
||||
await excluded.waitFor({ state: "visible" });
|
||||
check(
|
||||
(await excluded.innerText()).includes(
|
||||
"different device, security key, or passkey provider",
|
||||
),
|
||||
"exclusion needs a concrete alternative",
|
||||
);
|
||||
check(
|
||||
(await excluded.innerText()).includes("do not delete"),
|
||||
"exclusion must preserve working access",
|
||||
);
|
||||
check(
|
||||
completions() === 0,
|
||||
"native exclusion must not submit completion",
|
||||
);
|
||||
const afterExcluded = await keys(page, user);
|
||||
check(
|
||||
afterExcluded.length === 1 &&
|
||||
afterExcluded[0].key_reference === oldReference,
|
||||
"exclusion changed old key",
|
||||
);
|
||||
await owner(page, user);
|
||||
rows.push({
|
||||
resident,
|
||||
authenticator: "same",
|
||||
outcome: "native-exclusion-no-completion-old-key-preserved",
|
||||
elapsed_ms: Math.round(performance.now() - sameStarted),
|
||||
});
|
||||
|
||||
phase = `other-authenticator-pending-resident-${resident}`;
|
||||
const otherStarted = performance.now();
|
||||
// CDPの仮想認証器選択は物理providerの選択UIを再現しない。別targetに別認証器を置き、
|
||||
// 旧targetと旧鍵は生存させる。共有contextの既存sessionを使い、cookie/鍵の移送はしない。
|
||||
const legacyPage = page;
|
||||
const legacyCdp = cdp;
|
||||
page = await context.newPage();
|
||||
page.on("pageerror", () => ++pageErrors);
|
||||
page.on("request", (r) => {
|
||||
if (
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
||||
) ++completionPosts;
|
||||
});
|
||||
cdp = await context.newCDPSession(page);
|
||||
await cdp.send("WebAuthn.enable", { enableUI: false });
|
||||
const newer = await authenticator(cdp, true, false);
|
||||
await account(page);
|
||||
// options応答後、native操作はCTAPのpresence=falseで止める。timeout/sleepでpromptを推測しない。
|
||||
const beforeAddition = await owner(page, user);
|
||||
const additionOptions = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
||||
);
|
||||
const additionComplete = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
||||
);
|
||||
await add().click();
|
||||
const optionResponse = await additionOptions;
|
||||
check(
|
||||
optionResponse.status() === 200,
|
||||
"other authenticator options status",
|
||||
);
|
||||
let optionsInput = optionResponse.request().postDataJSON();
|
||||
check(
|
||||
optionsInput.expected_user_id === user.user_id &&
|
||||
optionsInput.expected_account_id === user.account_id &&
|
||||
optionsInput.expected_authentication_reference ===
|
||||
beforeAddition.authentication_reference,
|
||||
"addition must fence displayed owner and session",
|
||||
);
|
||||
let wire = await optionResponse.json();
|
||||
check(
|
||||
wire.public_key.publicKey.excludeCredentials.length === 1,
|
||||
"old credential must remain excluded",
|
||||
);
|
||||
check(
|
||||
wire.public_key.publicKey.authenticatorSelection.userVerification ===
|
||||
"required",
|
||||
"addition must require UV",
|
||||
);
|
||||
check(
|
||||
wire.public_key.publicKey.authenticatorSelection.residentKey ===
|
||||
"required",
|
||||
"product addition must require residency",
|
||||
);
|
||||
const pending = page.getByRole("status").filter({
|
||||
hasText: "Waiting for passkey addition",
|
||||
});
|
||||
await pending.waitFor({ state: "visible" });
|
||||
check(
|
||||
await page.getByRole("heading", { name: "Account", exact: true })
|
||||
.isVisible(),
|
||||
"account hidden while native pending",
|
||||
);
|
||||
check(
|
||||
await add().isDisabled() && completions() === 0,
|
||||
"pending must prevent duplicate/early completion",
|
||||
);
|
||||
for (
|
||||
const viewport of [{ width: 1280, height: 900 }, {
|
||||
width: 390,
|
||||
height: 844,
|
||||
}]
|
||||
) {
|
||||
await page.setViewportSize(viewport);
|
||||
await pending.scrollIntoViewIfNeeded();
|
||||
check(
|
||||
await pending.isVisible(),
|
||||
"pending must remain visible at viewport",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: join(
|
||||
artifacts,
|
||||
`native-addition-pending-${resident}-${viewport.width}.png`,
|
||||
),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
// presence=falseで既に待機中のCTAP要求を後からtrueにして解放できるとは仮定しない。
|
||||
// pending/取消と成功を分ける。送信前の停止を確認し、別ceremonyを明示開始する。
|
||||
await page.getByRole("button", { name: "Stop waiting", exact: true })
|
||||
.click();
|
||||
await page.getByRole("alert").filter({
|
||||
hasText: "Completion was not sent.",
|
||||
}).waitFor();
|
||||
check(
|
||||
completions() === 0 && (await keys(page, user)).length === 1,
|
||||
"cancel before touch must not save or complete",
|
||||
);
|
||||
await presence(cdp, newer, true);
|
||||
const freshOptions = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
||||
);
|
||||
await add().click();
|
||||
const freshResponse = await freshOptions;
|
||||
check(freshResponse.status() === 200, "fresh addition options status");
|
||||
optionsInput = freshResponse.request().postDataJSON();
|
||||
wire = await freshResponse.json();
|
||||
phase = `other-authenticator-completion-resident-${resident}`;
|
||||
const complete = await additionComplete;
|
||||
check(complete.status() === 200, "native addition complete status");
|
||||
const issued = await complete.json();
|
||||
check(
|
||||
issued.user.user_id === user.user_id &&
|
||||
issued.user.account_id === user.account_id &&
|
||||
typeof issued.authentication_reference === "string",
|
||||
"addition complete owner/reference",
|
||||
);
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Passkey added to your account",
|
||||
}).waitFor({ state: "visible" });
|
||||
await owner(page, user, issued.authentication_reference);
|
||||
check(completions() === 1, "addition must submit exactly once");
|
||||
// 元のbinding referenceはreceipt照合用。Cookie発行後も同じ現在ownerから確認する。
|
||||
const receipt = await page.request.post(
|
||||
`${origin}/api/auth/passkeys/addition/result`,
|
||||
{
|
||||
headers: { Origin: origin },
|
||||
data: {
|
||||
challenge_id: wire.challenge_id,
|
||||
expected_user_id: user.user_id,
|
||||
expected_account_id: user.account_id,
|
||||
expected_authentication_reference:
|
||||
optionsInput.expected_authentication_reference,
|
||||
},
|
||||
},
|
||||
);
|
||||
check(
|
||||
receipt.status() === 200,
|
||||
"saved addition receipt after Cookie issuance",
|
||||
);
|
||||
const saved = await receipt.json();
|
||||
check(
|
||||
saved.status === "saved" && saved.user.user_id === user.user_id &&
|
||||
saved.user.account_id === user.account_id,
|
||||
"receipt must identify saved owner",
|
||||
);
|
||||
const added = await keys(page, user);
|
||||
check(
|
||||
added.length === 2 &&
|
||||
added.some((key) => key.key_reference === oldReference),
|
||||
"addition must retain older key",
|
||||
);
|
||||
const newKey = added.find((key) => key.key_reference !== oldReference);
|
||||
check(
|
||||
newKey && saved.key.key_reference === newKey.key_reference &&
|
||||
newKey.normal_login_verified_at === null,
|
||||
"storage must not assert normal login",
|
||||
);
|
||||
await logout(page);
|
||||
phase = `handle-free-login-resident-${resident}`;
|
||||
await login(page, user, false);
|
||||
const verified = await keys(page, user);
|
||||
check(
|
||||
verified.length === 2 &&
|
||||
verified.find((key) => key.key_reference === newKey.key_reference)
|
||||
?.normal_login_verified_at != null,
|
||||
"actual handle-free verifier success must mark the new key",
|
||||
);
|
||||
check(
|
||||
verified.find((key) => key.key_reference === oldReference)
|
||||
?.normal_login_verified_at === null,
|
||||
"normal login must not mark the unused legacy key",
|
||||
);
|
||||
await logout(page);
|
||||
phase = `older-key-login-after-resident-${resident}`;
|
||||
await presence(cdp, newer, false);
|
||||
page = legacyPage;
|
||||
cdp = legacyCdp;
|
||||
await presence(cdp, old, true);
|
||||
await login(page, user, true);
|
||||
const retained = await keys(page, user);
|
||||
check(
|
||||
retained.length === 2 &&
|
||||
retained.find((key) => key.key_reference === oldReference)
|
||||
?.last_used_at != null,
|
||||
"older key must remain usable after migration",
|
||||
);
|
||||
check(
|
||||
retained.find((key) => key.key_reference === newKey.key_reference)
|
||||
?.normal_login_verified_at != null,
|
||||
"older login must preserve new key verification",
|
||||
);
|
||||
check(pageErrors === 0, "product page emitted an uncaught error");
|
||||
rows.push({
|
||||
resident,
|
||||
authenticator: "other",
|
||||
outcome:
|
||||
"saved-correct-owner-handle-free-verified-older-login-preserved",
|
||||
elapsed_ms: Math.round(performance.now() - otherStarted),
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
join(artifacts, "native-migration-result.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "passed",
|
||||
boundary:
|
||||
"real Backend/static shell/native Chromium CTAP2; synthetic legacy creation override only",
|
||||
chromium: browser.version(),
|
||||
rows,
|
||||
elapsed_ms: Math.round(performance.now() - started),
|
||||
viewports: ["1280x900", "390x844"],
|
||||
physical_smartphone: "unconfirmed",
|
||||
physical_authenticator: "unconfirmed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.error(
|
||||
`native-migration: PASS ${rows.length} matrix rows; ${
|
||||
Math.round(performance.now() - started)
|
||||
}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostic = error instanceof NativeCheckError
|
||||
? error.message
|
||||
: "browser-or-tool-operation-failed";
|
||||
await Deno.writeTextFile(
|
||||
join(artifacts, "native-migration-result.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "failed",
|
||||
phase,
|
||||
diagnostic,
|
||||
rows,
|
||||
elapsed_ms: Math.round(performance.now() - started),
|
||||
physical_smartphone: "unconfirmed",
|
||||
physical_authenticator: "unconfirmed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
// Playwrightの例外にはrequest/引数を含み得るため本文やstackを保存しない。
|
||||
console.error(
|
||||
`native-migration: FAIL phase=${phase}; completed_rows=${rows.length}; elapsed_ms=${
|
||||
Math.round(performance.now() - started)
|
||||
}`,
|
||||
);
|
||||
Deno.exitCode = 1;
|
||||
} finally {
|
||||
Deno.removeSignalListener("SIGTERM", terminate);
|
||||
await browser?.close();
|
||||
await server.shutdown();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// 実Backendへの透過proxyと本番SPA配信をbrowser suite間で共有する。
|
||||
// Cookie/Origin/署名検証/認可は置換しない。fixture入口は呼出側が明示的に渡す。
|
||||
import { extname, join, normalize } from "@std/path";
|
||||
|
||||
export async function serveProductionShell(
|
||||
backend: string,
|
||||
origin: string,
|
||||
build: string,
|
||||
fixture?: (request: Request) => Promise<Response | undefined>,
|
||||
) {
|
||||
for (const address of [backend, origin]) {
|
||||
const url = new URL(address);
|
||||
if (
|
||||
url.protocol !== "http:" ||
|
||||
!["localhost", "127.0.0.1"].includes(url.hostname) || !url.port
|
||||
) {
|
||||
throw new Error("Browser E2E requires explicit owned loopback endpoints");
|
||||
}
|
||||
}
|
||||
await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".woff2": "font/woff2",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
return Deno.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(new URL(origin).port),
|
||||
onListen() {},
|
||||
}, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const special = await fixture?.(request);
|
||||
if (special) return special;
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
return await fetch(new URL(url.pathname + url.search, backend), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: ["GET", "HEAD"].includes(request.method)
|
||||
? undefined
|
||||
: await request.arrayBuffer(),
|
||||
redirect: "manual",
|
||||
});
|
||||
}
|
||||
const relative = normalize(
|
||||
url.pathname.replace(/^\/+/, "") || "index.html",
|
||||
);
|
||||
if (relative.startsWith("..")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
let file = join(build, relative);
|
||||
try {
|
||||
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
||||
return new Response(await Deno.readFile(file), {
|
||||
headers: {
|
||||
"content-type": mime[extname(file)] ?? "application/octet-stream",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
if (url.pathname.startsWith("/_app/")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
return new Response(await Deno.readFile(join(build, "index.html")), {
|
||||
headers: { "content-type": mime[".html"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"source_revision": "1639be98411921308238b3a6a6e75c784c045156",
|
||||
"model": "codex-oauth/gpt-6.1-sol",
|
||||
"worker_id": "01a1657d-b762-7791-87ae-a49f28cadbce",
|
||||
"session_id": "01a1657d-b818-77c1-bd73-175a27562f63",
|
||||
"rows": [
|
||||
{
|
||||
"entry_id": "01a1657f-18bf-7a03-b8b8-953d27041fe0",
|
||||
"timestamp": 1792704190655,
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"content": "これは保存と復元を確認するための人工的な会話です。合言葉は「青い灯台」です。覚えたことを一文で返してください。Toolは使わないでください。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"entry_id": "01a1657f-1f55-71d3-b418-8199b863f59a",
|
||||
"timestamp": 1792704192341,
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "合言葉は「青い灯台」と覚えました。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"entry_id": "01a1657f-3053-71f3-bf4f-91290d82488b",
|
||||
"timestamp": 1792704196691,
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"content": "先ほどの合言葉は何でしたか?合言葉だけを答えてください。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"entry_id": "01a1657f-36ef-78b1-b0c8-b141f13b0c7b",
|
||||
"timestamp": 1792704198383,
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "青い灯台"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"source_revision": "1639be98411921308238b3a6a6e75c784c045156",
|
||||
"runtime_schema_version": 12,
|
||||
"session_schema_version": 6,
|
||||
"workspace_id": "01a16553-b901-7191-8ceb-8e2306fe3da9",
|
||||
"runtime_id": "fixture-951707ee-93ca-4205-9c12-45f08a104a74",
|
||||
"worker_id": "01a1657d-b762-7791-87ae-a49f28cadbce",
|
||||
"session_id": "01a1657d-b818-77c1-bd73-175a27562f63",
|
||||
"segment_id": "01a1657d-b818-77c1-bd73-176172a4bd82",
|
||||
"model": "codex-oauth/gpt-6.1-sol",
|
||||
"state": "stopped",
|
||||
"acquisition": "Real isolated Codex OAuth two-turn artificial conversation; typed offline StoppedWorkerTemplate extraction. Session bytes unchanged; environment credentials and execution receipts excluded.",
|
||||
"source_file_sha256": {
|
||||
"worker.json": "912e620013a17f05c1aaf900da48c9ee0ecb1f54121efc3721e03f8755eb5eca",
|
||||
"metadata.json": "657928a89ec621bd092b86924201ed7b562e9683261789ce18bdb1d248bdc450",
|
||||
"session/session.json": "22b01daab68a28b9777a1f7abb9ef9f7f6cbae113089446602f5d15421e7c7f3",
|
||||
"session/segments/01a1657d-b818-77c1-bd73-176172a4bd82.jsonl": "7c5663bb06d9406d5bd0a5086159b31c0d7799458fcb1c6f81e18b63c49f4df5"
|
||||
},
|
||||
"files": {
|
||||
"worker-template.json": {
|
||||
"bytes": 48343,
|
||||
"sha256": "8bf6ce3c71c4f05c82314de93c1e8e51b7502b00915b6adb8b33046944e463b2"
|
||||
},
|
||||
"expected.json": {
|
||||
"bytes": 1474,
|
||||
"sha256": "b9846db47ecb8f3f53b2abd9d5f0c766122f86015a7a7df7addf8beab3e9c55a"
|
||||
},
|
||||
"workspace-config.dcdl": {
|
||||
"bytes": 1141,
|
||||
"sha256": "9b07507b71733081f077e2e9968a7f1f676c916a243bb355d1813f067a55c415"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
profile = {
|
||||
default_profile = "project:fixture-chat";
|
||||
entries = [{
|
||||
selector = "project:fixture-chat";
|
||||
label = "Isolated Codex fixture conversation";
|
||||
description = "Artificial conversation only; no Workdir, Tools, Subject or background work.";
|
||||
profile = (import "$builtin/profiles/job.dcdl") // {
|
||||
slug = "fixture-chat";
|
||||
worker = { mode = "tools"; };
|
||||
model = { ref = "codex-oauth/gpt-6.1-sol"; };
|
||||
scope = "workspace_read";
|
||||
delegation_scope = "workspace_read";
|
||||
permissions = { default_action = "deny"; rule = []; };
|
||||
engine = { instruction = "default"; reasoning = "low"; max_tokens = 2048; max_turns = 4; language = "Japanese"; };
|
||||
session = { record_event_trace = false; };
|
||||
feature = { workdir_catalog = { enabled = false; }; };
|
||||
};
|
||||
}];
|
||||
};
|
||||
prompts = { default_prompt = "これは隔離環境の人工的な会話テストです。日本語で簡潔に応答してください。Toolや外部操作は行わず、ユーザーが会話中に提示した情報だけを使ってください。"; };
|
||||
} as WorkspaceConfigSchema
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Byte-preserving fixture transport, independent of the owning store's schema validation.
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{HarnessError, Result};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FixtureSource {
|
||||
root: PathBuf,
|
||||
hashes: BTreeMap<PathBuf, String>,
|
||||
}
|
||||
|
||||
impl FixtureSource {
|
||||
/// Record every supplied file, including the manifest. No fixture is generated
|
||||
/// or repaired here; the typed store importer must additionally validate it.
|
||||
pub fn capture(root: &Path) -> Result<Self> {
|
||||
let hashes = inventory(root)?;
|
||||
if hashes.is_empty() {
|
||||
return Err(HarnessError::Protocol("provided fixture is empty".into()));
|
||||
}
|
||||
Ok(Self {
|
||||
root: root.to_owned(),
|
||||
hashes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hashes(&self) -> &BTreeMap<PathBuf, String> {
|
||||
&self.hashes
|
||||
}
|
||||
|
||||
pub fn assert_unchanged(&self) -> Result<()> {
|
||||
if inventory(&self.root)? != self.hashes {
|
||||
return Err(HarnessError::Protocol(
|
||||
"provided fixture changed during execution".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The destination must not exist. A copy is never a link to the source.
|
||||
pub fn copy_to(&self, destination: &Path) -> Result<()> {
|
||||
self.assert_unchanged()?;
|
||||
let parent = destination
|
||||
.parent()
|
||||
.filter(|path| !path.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
if parent
|
||||
.canonicalize()?
|
||||
.starts_with(self.root.canonicalize()?)
|
||||
{
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture copy destination is inside the original".into(),
|
||||
));
|
||||
}
|
||||
fs::create_dir(destination)?;
|
||||
for (relative, expected) in &self.hashes {
|
||||
let bytes = fs::read(self.root.join(relative))?;
|
||||
if hash(&bytes) != *expected {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture changed while copying".into(),
|
||||
));
|
||||
}
|
||||
let target = destination.join(relative);
|
||||
fs::create_dir_all(target.parent().unwrap())?;
|
||||
use std::io::Write;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(target)?;
|
||||
file.write_all(&bytes)?;
|
||||
}
|
||||
if inventory(destination)? != self.hashes {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture copy failed hash verification".into(),
|
||||
));
|
||||
}
|
||||
self.assert_unchanged()
|
||||
}
|
||||
}
|
||||
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
format!(
|
||||
"sha256:{}",
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
)
|
||||
}
|
||||
|
||||
fn inventory(root: &Path) -> Result<BTreeMap<PathBuf, String>> {
|
||||
fn visit(root: &Path, path: &Path, files: &mut BTreeMap<PathBuf, String>) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if metadata.is_dir() {
|
||||
for entry in fs::read_dir(path)? {
|
||||
visit(root, &entry?.path(), files)?;
|
||||
}
|
||||
} else if metadata.is_file() {
|
||||
files.insert(
|
||||
path.strip_prefix(root).unwrap().to_owned(),
|
||||
hash(&fs::read(path)?),
|
||||
);
|
||||
} else {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture contains a symlink or special file".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
if !fs::symlink_metadata(root)?.is_dir() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture source must be a real directory".into(),
|
||||
));
|
||||
}
|
||||
let mut files = BTreeMap::new();
|
||||
visit(root, root, &mut files)?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// These are transport-only byte samples, never saved Worker substitutes.
|
||||
#[test]
|
||||
fn copies_are_independent_and_source_mutation_is_detected() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let source = temp.path().join("source");
|
||||
fs::create_dir(&source)?;
|
||||
fs::write(source.join("bytes"), b"original")?;
|
||||
let fixture = FixtureSource::capture(&source)?;
|
||||
assert!(fixture.copy_to(&source.join("nested-copy")).is_err());
|
||||
assert!(!source.join("nested-copy").exists());
|
||||
fixture.assert_unchanged()?;
|
||||
let first = temp.path().join("first");
|
||||
let second = temp.path().join("second");
|
||||
fixture.copy_to(&first)?;
|
||||
fixture.copy_to(&second)?;
|
||||
fs::write(first.join("bytes"), b"changed")?;
|
||||
fs::remove_dir_all(first)?;
|
||||
assert_eq!(fs::read(second.join("bytes"))?, b"original");
|
||||
fixture.assert_unchanged()?;
|
||||
fs::write(source.join("bytes"), b"changed")?;
|
||||
assert!(fixture.assert_unchanged().is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_empty_existing_destination_and_extra_file() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
assert!(FixtureSource::capture(&temp.path().join("missing")).is_err());
|
||||
assert!(FixtureSource::capture(temp.path()).is_err());
|
||||
fs::write(temp.path().join("bytes"), b"original")?;
|
||||
let source = FixtureSource::capture(temp.path())?;
|
||||
assert!(source.copy_to(temp.path()).is_err());
|
||||
fs::write(temp.path().join("extra"), b"new")?;
|
||||
assert!(source.assert_unchanged().is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symbolic_links() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
fs::write(temp.path().join("bytes"), b"original")?;
|
||||
std::os::unix::fs::symlink("bytes", temp.path().join("link"))?;
|
||||
assert!(FixtureSource::capture(temp.path()).is_err());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -1268,7 +1268,11 @@ impl Drop for PanelHarness {
|
||||
}
|
||||
}
|
||||
|
||||
mod fixture_source;
|
||||
pub use fixture_source::FixtureSource;
|
||||
mod worker_environment;
|
||||
mod worker_session;
|
||||
pub use worker_session::{WorkerSessionSubscription, validate_full_snapshot};
|
||||
|
||||
pub use worker_environment::{
|
||||
WorkerBinaryProviderInfo, WorkerE2eEnvironment, WorkerEnvironmentCleanupReport,
|
||||
@@ -1627,6 +1631,8 @@ fn resolve_yoi_binary() -> Result<BinaryProviderInfo> {
|
||||
"e2e-test".to_owned(),
|
||||
"--bin".to_owned(),
|
||||
"yoi".to_owned(),
|
||||
"--target-dir".to_owned(),
|
||||
tui_product_target_dir()?.to_string_lossy().into_owned(),
|
||||
];
|
||||
if test_profile() == "release" {
|
||||
args.push("--release".to_owned());
|
||||
@@ -1648,7 +1654,9 @@ fn resolve_yoi_binary() -> Result<BinaryProviderInfo> {
|
||||
});
|
||||
}
|
||||
|
||||
let binary = current_target_profile_dir()?.join(binary_name());
|
||||
let binary = tui_product_target_dir()?
|
||||
.join(test_profile())
|
||||
.join(binary_name());
|
||||
let info = BinaryProviderInfo {
|
||||
provider: "cargo-build".to_owned(),
|
||||
binary,
|
||||
@@ -1675,6 +1683,26 @@ fn workspace_root() -> Result<PathBuf> {
|
||||
.ok_or_else(|| HarnessError::Protocol("could not resolve workspace root".to_owned()))
|
||||
}
|
||||
|
||||
/// Product processes must never reuse the target used by test-support builds.
|
||||
fn product_target_dir() -> Result<PathBuf> {
|
||||
dedicated_product_target("e2e-products")
|
||||
}
|
||||
|
||||
// Existing PTY tests need their observer feature, never on restore binaries.
|
||||
fn tui_product_target_dir() -> Result<PathBuf> {
|
||||
dedicated_product_target("e2e-tui-products")
|
||||
}
|
||||
|
||||
fn dedicated_product_target(name: &str) -> Result<PathBuf> {
|
||||
let target = workspace_root()?.join("target").join(name);
|
||||
if current_target_profile_dir()?.parent() == Some(target.as_path()) {
|
||||
return Err(HarnessError::Protocol(
|
||||
"run support tests outside reserved product binary targets".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn current_target_profile_dir() -> Result<PathBuf> {
|
||||
let mut path = std::env::current_exe()?;
|
||||
while let Some(name) = path.file_name().and_then(|name| name.to_str()) {
|
||||
|
||||
@@ -13,8 +13,8 @@ use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{
|
||||
FixtureAllocation, HarnessError, Result, allocate_fixture, command_display,
|
||||
current_target_profile_dir, now_ms, test_profile, workspace_root, yoi_binary_info,
|
||||
FixtureAllocation, HarnessError, Result, allocate_fixture, command_display, now_ms,
|
||||
product_target_dir, test_profile, workspace_root,
|
||||
};
|
||||
|
||||
const STARTUP_WAIT: Duration = Duration::from_secs(15);
|
||||
@@ -61,22 +61,20 @@ pub fn worker_binary_provider_info() -> Result<WorkerBinaryProviderInfo> {
|
||||
fn resolve_worker_binaries() -> Result<WorkerBinaryProviderInfo> {
|
||||
let workspace_root = workspace_root()?;
|
||||
let profile = test_profile();
|
||||
let yoi = yoi_binary_info()?;
|
||||
let mut records = vec![binary_record(
|
||||
"yoi",
|
||||
yoi.binary,
|
||||
yoi.provider,
|
||||
yoi.build_command,
|
||||
)?];
|
||||
|
||||
let mut records = Vec::new();
|
||||
let yoi_override = resolve_override("YOI_E2E_BIN", &workspace_root)?;
|
||||
let server_override = resolve_override("YOI_E2E_SERVER_BIN", &workspace_root)?;
|
||||
let runtime_override = resolve_override("YOI_E2E_RUNTIME_BIN", &workspace_root)?;
|
||||
let mut build_command = None;
|
||||
if server_override.is_none() || runtime_override.is_none() {
|
||||
if yoi_override.is_none() || server_override.is_none() || runtime_override.is_none() {
|
||||
let cargo = PathBuf::from(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
|
||||
let mut args = vec![
|
||||
"build".to_string(),
|
||||
"-p".to_string(),
|
||||
"yoi".to_string(),
|
||||
"--bin".to_string(),
|
||||
"yoi".to_string(),
|
||||
"-p".to_string(),
|
||||
"yoi-workspace-server".to_string(),
|
||||
"--bin".to_string(),
|
||||
"yoi-server".to_string(),
|
||||
@@ -84,6 +82,8 @@ fn resolve_worker_binaries() -> Result<WorkerBinaryProviderInfo> {
|
||||
"worker-runtime".to_string(),
|
||||
"--bin".to_string(),
|
||||
"yoi-runtime".to_string(),
|
||||
"--target-dir".to_string(),
|
||||
product_target_dir()?.to_string_lossy().into_owned(),
|
||||
];
|
||||
if profile == "release" {
|
||||
args.push("--release".to_string());
|
||||
@@ -106,7 +106,16 @@ fn resolve_worker_binaries() -> Result<WorkerBinaryProviderInfo> {
|
||||
build_command = Some(rendered);
|
||||
}
|
||||
|
||||
let profile_dir = current_target_profile_dir()?;
|
||||
let profile_dir = product_target_dir()?.join(&profile);
|
||||
let (yoi_path, yoi_provider, yoi_build) = match yoi_override {
|
||||
Some(path) => (path, "YOI_E2E_BIN".to_string(), None),
|
||||
None => (
|
||||
profile_dir.join(executable_name("yoi")),
|
||||
"cargo-build".to_string(),
|
||||
build_command.clone(),
|
||||
),
|
||||
};
|
||||
records.push(binary_record("yoi", yoi_path, yoi_provider, yoi_build)?);
|
||||
let (server_path, server_provider, server_build) = match server_override {
|
||||
Some(path) => (path, "YOI_E2E_SERVER_BIN".to_string(), None),
|
||||
None => (
|
||||
@@ -231,6 +240,7 @@ pub struct WorkerE2eEnvironment {
|
||||
pub runtime_url: Option<String>,
|
||||
server: Option<ManagedProcess>,
|
||||
runtime: Option<ManagedProcess>,
|
||||
runtime_public_identity: Option<Value>,
|
||||
access_token: Option<String>,
|
||||
client: Client,
|
||||
binaries: WorkerBinaryProviderInfo,
|
||||
@@ -292,6 +302,7 @@ impl WorkerE2eEnvironment {
|
||||
runtime_url: None,
|
||||
server: None,
|
||||
runtime: None,
|
||||
runtime_public_identity: None,
|
||||
access_token: None,
|
||||
client,
|
||||
binaries,
|
||||
@@ -374,6 +385,47 @@ impl WorkerE2eEnvironment {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a store populated by the Backend's typed test-support API. The seed
|
||||
/// supplies fixed catalog IDs, but the bearer must be freshly issued for this
|
||||
/// environment. No database mutation or fixture credential import occurs here.
|
||||
pub fn start_seeded_backend(
|
||||
&mut self,
|
||||
workspace_id: String,
|
||||
runtime_id: String,
|
||||
access_token: String,
|
||||
) -> Result<()> {
|
||||
if self.server.is_some() || self.runtime.is_some() || self.workspace_id.is_some() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture seed requires an unstarted environment".into(),
|
||||
));
|
||||
}
|
||||
if workspace_id.is_empty() || runtime_id.is_empty() || access_token.is_empty() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"fixture seed omitted an identity or fresh bearer".into(),
|
||||
));
|
||||
}
|
||||
if !self.server_data_dir.join("server.db").is_file() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"typed Backend seed has not created server.db".into(),
|
||||
));
|
||||
}
|
||||
if self.runtime_public_identity.is_some() {
|
||||
if self.runtime_id != runtime_id {
|
||||
return Err(HarnessError::Protocol(
|
||||
"seeded Runtime ID differs from initialized identity".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
|
||||
}
|
||||
self.workspace_id = Some(workspace_id);
|
||||
self.runtime_id = runtime_id;
|
||||
self.access_token = Some(access_token);
|
||||
self.start_server_process()?;
|
||||
self.export_workspace_public_identity()?;
|
||||
self.write_metadata("seeded-backend-running")
|
||||
}
|
||||
|
||||
pub fn start_backend(&mut self) -> Result<()> {
|
||||
self.start_empty_backend()?;
|
||||
let server_binary = self.binaries.binary("yoi-server")?;
|
||||
@@ -417,7 +469,15 @@ impl WorkerE2eEnvironment {
|
||||
"create Workspace",
|
||||
)?;
|
||||
let workspace_id = json_string(&created, "/workspace/workspace_id")?;
|
||||
self.workspace_id = Some(workspace_id.clone());
|
||||
self.workspace_id = Some(workspace_id);
|
||||
self.export_workspace_public_identity()?;
|
||||
self.write_metadata("backend-running")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn export_workspace_public_identity(&self) -> Result<()> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let workspace_id = self.required_workspace_id()?;
|
||||
let identity = self.request_json(
|
||||
self.client.get(format!(
|
||||
"{server_url}/api/w/{workspace_id}/settings/signing-identity"
|
||||
@@ -429,17 +489,27 @@ impl WorkerE2eEnvironment {
|
||||
})?;
|
||||
let bundle_path = self.root.join("workspace-public-identity.json");
|
||||
fs::write(&bundle_path, serde_json::to_vec_pretty(&public_bundle)?)?;
|
||||
self.write_metadata("backend-running")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_runtime(&mut self) -> Result<()> {
|
||||
let bundle_path = self.root.join("workspace-public-identity.json");
|
||||
let server_url = self.required_server_url()?.to_string();
|
||||
let workspace_id = self.required_workspace_id()?.to_string();
|
||||
let runtime_binary = self.binaries.binary("yoi-runtime")?;
|
||||
self.start_runtime_with_seed(|_| Ok(()))?;
|
||||
self.assert_connected_and_empty()
|
||||
}
|
||||
|
||||
/// Generate fresh keys before offline Backend registration when fixed IDs are
|
||||
/// required. Returns only the public bundle; no fixture keys are accepted.
|
||||
pub fn initialize_runtime_identity(&mut self, runtime_id: String) -> Result<Value> {
|
||||
if self.runtime_public_identity.is_some() || self.runtime.is_some() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"Runtime identity is already initialized".into(),
|
||||
));
|
||||
}
|
||||
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
|
||||
self.runtime_id = runtime_id;
|
||||
let binary = self.binaries.binary("yoi-runtime")?;
|
||||
self.run_command(
|
||||
&runtime_binary,
|
||||
&binary,
|
||||
&[
|
||||
"identity",
|
||||
"init",
|
||||
@@ -450,8 +520,8 @@ impl WorkerE2eEnvironment {
|
||||
],
|
||||
false,
|
||||
)?;
|
||||
let runtime_identity = self.run_command(
|
||||
&runtime_binary,
|
||||
let identity = self.run_command(
|
||||
&binary,
|
||||
&[
|
||||
"identity",
|
||||
"show",
|
||||
@@ -461,7 +531,31 @@ impl WorkerE2eEnvironment {
|
||||
],
|
||||
false,
|
||||
)?;
|
||||
let runtime_identity: Value = serde_json::from_slice(&runtime_identity.stdout)?;
|
||||
let identity: Value = serde_json::from_slice(&identity.stdout)?;
|
||||
self.runtime_public_identity = Some(identity.clone());
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Initialize fresh Runtime keys/trust, then invoke the typed Runtime seed
|
||||
/// before starting its process. The actual Backend URL is now available.
|
||||
pub fn start_runtime_with_seed(
|
||||
&mut self,
|
||||
seed: impl FnOnce(&Self) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
if self.runtime.is_some() {
|
||||
return Err(HarnessError::Protocol("Runtime is already running".into()));
|
||||
}
|
||||
if self.runtime_public_identity.is_none() {
|
||||
self.initialize_runtime_identity(self.runtime_id.clone())?;
|
||||
}
|
||||
let runtime_identity = self
|
||||
.runtime_public_identity
|
||||
.clone()
|
||||
.expect("initialized identity");
|
||||
let bundle_path = self.root.join("workspace-public-identity.json");
|
||||
let server_url = self.required_server_url()?.to_string();
|
||||
let workspace_id = self.required_workspace_id()?.to_string();
|
||||
let runtime_binary = self.binaries.binary("yoi-runtime")?;
|
||||
self.run_command(
|
||||
&runtime_binary,
|
||||
&[
|
||||
@@ -497,6 +591,8 @@ impl WorkerE2eEnvironment {
|
||||
));
|
||||
}
|
||||
|
||||
seed(self)?;
|
||||
self.write_metadata("runtime-seeded")?;
|
||||
let mut command = self.isolated_command(&runtime_binary);
|
||||
command.args([
|
||||
"--bind",
|
||||
@@ -519,22 +615,38 @@ impl WorkerE2eEnvironment {
|
||||
self.runtime = Some(runtime);
|
||||
self.runtime_url = Some(runtime_url.clone());
|
||||
|
||||
let registered = self.request_json(
|
||||
self.client
|
||||
.get(format!("{server_url}/api/w/{workspace_id}/runtimes")),
|
||||
"observe Runtime registration before endpoint update",
|
||||
)?;
|
||||
let expected_binding_id = registered
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.get("runtime_id").and_then(Value::as_str) == Some(self.runtime_id.as_str())
|
||||
})
|
||||
})
|
||||
.and_then(|item| item.pointer("/management/binding/binding_id"))
|
||||
.cloned();
|
||||
self.request_json(
|
||||
self.client
|
||||
.post(format!("{server_url}/api/w/{workspace_id}/runtimes"))
|
||||
.json(&serde_json::json!({
|
||||
"public_bundle": runtime_identity,
|
||||
"display_name": format!("E2E {}", self.runtime_id),
|
||||
"endpoint": runtime_url
|
||||
"endpoint": runtime_url,
|
||||
"expected_binding_id": expected_binding_id,
|
||||
})),
|
||||
"register Runtime",
|
||||
)?;
|
||||
self.assert_connected_and_empty()?;
|
||||
self.assert_connected()?;
|
||||
self.write_metadata("running")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn assert_connected_and_empty(&self) -> Result<()> {
|
||||
pub fn assert_connected(&self) -> Result<()> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let workspace_id = self.required_workspace_id()?;
|
||||
// connection testは実署名要求。保存済みの接続flagを認証成功の代わりにしない。
|
||||
@@ -574,11 +686,22 @@ impl WorkerE2eEnvironment {
|
||||
self.runtime_id
|
||||
)));
|
||||
}
|
||||
let workers = self.request_json(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_workers(&self) -> Result<Value> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let workspace_id = self.required_workspace_id()?;
|
||||
self.request_json(
|
||||
self.client
|
||||
.get(format!("{server_url}/api/w/{workspace_id}/workers")),
|
||||
"list Workers",
|
||||
)?;
|
||||
)
|
||||
}
|
||||
|
||||
pub fn assert_connected_and_empty(&self) -> Result<()> {
|
||||
self.assert_connected()?;
|
||||
let workers = self.list_workers()?;
|
||||
if workers
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
@@ -591,6 +714,104 @@ impl WorkerE2eEnvironment {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn worker_url(&self, worker_id: &str, suffix: &str) -> Result<String> {
|
||||
let mut url = reqwest::Url::parse(self.required_server_url()?)
|
||||
.map_err(|error| HarnessError::Protocol(format!("invalid Server URL: {error}")))?;
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| HarnessError::Protocol("Server URL has no path segments".into()))?
|
||||
.extend([
|
||||
"api",
|
||||
"w",
|
||||
self.required_workspace_id()?,
|
||||
"runtimes",
|
||||
&self.runtime_id,
|
||||
"workers",
|
||||
worker_id,
|
||||
]);
|
||||
if !suffix.is_empty() {
|
||||
url.path_segments_mut().unwrap().extend(suffix.split('/'));
|
||||
}
|
||||
Ok(url.into())
|
||||
}
|
||||
|
||||
pub fn worker_detail(&self, worker_id: &str) -> Result<Value> {
|
||||
self.request_json(
|
||||
self.client.get(self.worker_url(worker_id, "")?),
|
||||
"read Worker detail",
|
||||
)
|
||||
}
|
||||
|
||||
/// Exactly one restore submission. No retries, replacement token or new intent.
|
||||
pub fn restore_worker(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
request_id: &str,
|
||||
expected_observation_token: &str,
|
||||
) -> Result<Value> {
|
||||
self.request_json(
|
||||
self.client
|
||||
.post(self.worker_url(worker_id, "restore")?)
|
||||
.timeout(STARTUP_WAIT)
|
||||
.json(&serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"expected_observation_token": expected_observation_token,
|
||||
})),
|
||||
"restore Worker",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stop_worker(&self, worker_id: &str) -> Result<Value> {
|
||||
self.request_json(
|
||||
self.client
|
||||
.post(self.worker_url(worker_id, "stop")?)
|
||||
.json(&serde_json::json!({})),
|
||||
"stop Worker",
|
||||
)
|
||||
}
|
||||
|
||||
/// Authorization stays in memory; callers must not persist or debug this request.
|
||||
pub fn worker_protocol_request(&self, worker_id: &str) -> Result<reqwest::blocking::Request> {
|
||||
let token = self.access_token.as_deref().ok_or_else(|| {
|
||||
HarnessError::Protocol("Backend access token is not initialized".into())
|
||||
})?;
|
||||
self.client
|
||||
.get(self.worker_url(worker_id, "protocol/ws")?)
|
||||
.bearer_auth(token)
|
||||
.build()
|
||||
.map_err(|_| HarnessError::Protocol("build Worker protocol request".into()))
|
||||
}
|
||||
|
||||
/// The other environment must reject this environment's fresh bearer even
|
||||
/// when both contain identical Workspace/Runtime/Worker IDs. Never logs tokens.
|
||||
pub fn assert_authentication_isolated_from(&self, other: &Self) -> Result<()> {
|
||||
let token = self
|
||||
.access_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| HarnessError::Protocol("missing source bearer".into()))?;
|
||||
if other.access_token.as_deref() == Some(token) {
|
||||
return Err(HarnessError::Protocol(
|
||||
"environments share authentication material".into(),
|
||||
));
|
||||
}
|
||||
let (server, workspace) = other.backend_target()?;
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{server}/api/w/{workspace}/workers"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.map_err(|_| {
|
||||
HarnessError::Protocol("cross-environment authorization request failed".into())
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
other.append_operation("reject other environment bearer", status)?;
|
||||
if status != 401 {
|
||||
return Err(HarnessError::Protocol(format!(
|
||||
"cross-environment bearer was not rejected with 401: {status}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn install_backend_token(&self, config_home: &Path) -> Result<PathBuf> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let access_token = self.access_token.as_deref().ok_or_else(|| {
|
||||
@@ -636,7 +857,7 @@ impl WorkerE2eEnvironment {
|
||||
"assignee": null,
|
||||
"readiness": null,
|
||||
"risk_flags": [],
|
||||
"workflow_state": "planning",
|
||||
"workflow_state": "open",
|
||||
"queued_by": null,
|
||||
"queued_at": null,
|
||||
"repository_key": null,
|
||||
@@ -853,6 +1074,27 @@ impl WorkerE2eEnvironment {
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// browser toolだけに必要なtool探索設定を引き継ぎ、profile・HOME・XDG・一時領域は
|
||||
/// このfixtureへ閉じ込める。DENO_DIR/PLAYWRIGHT_BROWSERS_PATHはインストール済みtoolの
|
||||
/// 供給元であり、製品データや認証情報を引き継ぐ経路にはしない。
|
||||
pub fn browser_command(&self, binary: &Path) -> Result<Command> {
|
||||
let snapshot: std::collections::HashMap<_, _> = std::env::vars_os().collect();
|
||||
let mut command = self.isolated_command(binary);
|
||||
for name in ["PATH", "DENO_DIR", "PLAYWRIGHT_BROWSERS_PATH"] {
|
||||
if let Some(value) = snapshot.get(std::ffi::OsStr::new(name)) {
|
||||
command.env(name, value);
|
||||
}
|
||||
}
|
||||
let temporary = self.root.join("browser-tmp");
|
||||
let cache = self.root.join("browser-cache");
|
||||
fs::create_dir_all(&temporary)?;
|
||||
fs::create_dir_all(&cache)?;
|
||||
command
|
||||
.env("TMPDIR", temporary)
|
||||
.env("XDG_CACHE_HOME", cache);
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn isolated_command(&self, binary: &Path) -> Command {
|
||||
let mut command = Command::new(binary);
|
||||
command
|
||||
@@ -1026,7 +1268,7 @@ impl ManagedProcess {
|
||||
fn shutdown(&mut self, timeout: Duration) -> Result<ExitStatus> {
|
||||
if let Some(status) = self.child.try_wait()? {
|
||||
self.join_readers();
|
||||
return Ok(status);
|
||||
return self.require_successful_exit(status);
|
||||
}
|
||||
let signal_result = unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) };
|
||||
if signal_result != 0 {
|
||||
@@ -1036,13 +1278,7 @@ impl ManagedProcess {
|
||||
loop {
|
||||
if let Some(status) = self.child.try_wait()? {
|
||||
self.join_readers();
|
||||
if status.success() {
|
||||
return Ok(status);
|
||||
}
|
||||
return Err(HarnessError::Protocol(format!(
|
||||
"{} did not complete graceful shutdown: {status}",
|
||||
self.name
|
||||
)));
|
||||
return self.require_successful_exit(status);
|
||||
}
|
||||
if start.elapsed() >= timeout {
|
||||
let _ = self.child.kill();
|
||||
@@ -1057,6 +1293,17 @@ impl ManagedProcess {
|
||||
}
|
||||
}
|
||||
|
||||
fn require_successful_exit(&self, status: ExitStatus) -> Result<ExitStatus> {
|
||||
if status.success() {
|
||||
Ok(status)
|
||||
} else {
|
||||
Err(HarnessError::Protocol(format!(
|
||||
"{} did not complete graceful shutdown: {status}",
|
||||
self.name
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn join_readers(&mut self) {
|
||||
for reader in self.readers.drain(..) {
|
||||
let _ = reader.join();
|
||||
@@ -1177,6 +1424,34 @@ fn io_error(operation: &str, error: std::io::Error) -> HarnessError {
|
||||
mod startup_address_tests {
|
||||
use super::parse_address_after;
|
||||
|
||||
#[test]
|
||||
fn shutdown_rejects_nonzero_exit_even_when_process_already_exited() -> super::Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
for code in [0, 7] {
|
||||
let mut command = std::process::Command::new("/bin/sh");
|
||||
command
|
||||
.env_clear()
|
||||
.current_dir(root.path())
|
||||
.args(["-c", &format!("exit {code}")]);
|
||||
let mut process = super::ManagedProcess::spawn(
|
||||
"already-exited",
|
||||
command,
|
||||
&root.path().join(format!("{code}.stdout")),
|
||||
&root.path().join(format!("{code}.stderr")),
|
||||
)?;
|
||||
// Reap first to deterministically exercise the initial try_wait branch.
|
||||
assert_eq!(process.child.wait()?.code(), Some(code));
|
||||
let result = process.shutdown(std::time::Duration::from_secs(1));
|
||||
if code == 0 {
|
||||
assert!(result?.success());
|
||||
} else {
|
||||
assert!(matches!(result, Err(super::HarnessError::Protocol(message))
|
||||
if message.contains("did not complete graceful shutdown")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_address_waits_for_complete_valid_socket_record() {
|
||||
let record = "yoi-server listening on http://127.0.0.1:49123\n";
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Live public Worker protocol assertions. No Submit/Resume/model request is sent.
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use protocol::{Event, SessionSnapshot, WorkerState};
|
||||
use tungstenite::{Message, WebSocket, client::IntoClientRequest};
|
||||
|
||||
use crate::{HarnessError, Result, WorkerE2eEnvironment};
|
||||
|
||||
const WS_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub struct WorkerSessionSubscription {
|
||||
socket: WebSocket<TcpStream>,
|
||||
}
|
||||
|
||||
impl WorkerSessionSubscription {
|
||||
pub fn connect(
|
||||
environment: &WorkerE2eEnvironment,
|
||||
worker_id: &str,
|
||||
expected: &SessionSnapshot,
|
||||
) -> Result<Self> {
|
||||
let authorized = environment.worker_protocol_request(worker_id)?;
|
||||
let mut url = authorized.url().clone();
|
||||
url.set_scheme("ws")
|
||||
.map_err(|_| error("invalid protocol URL"))?;
|
||||
let mut request = url
|
||||
.as_str()
|
||||
.into_client_request()
|
||||
.map_err(|_| error("build protocol handshake"))?;
|
||||
request.headers_mut().extend(authorized.headers().clone());
|
||||
// Only this environment's loopback endpoint is permitted, never a proxy.
|
||||
let address = url
|
||||
.socket_addrs(|| None)
|
||||
.map_err(|_| error("resolve protocol address"))?
|
||||
.into_iter()
|
||||
.find(|address| address.ip().is_loopback())
|
||||
.ok_or_else(|| error("protocol address is not loopback"))?;
|
||||
let stream = TcpStream::connect_timeout(&address, WS_WAIT)?;
|
||||
stream.set_read_timeout(Some(WS_WAIT))?;
|
||||
stream.set_write_timeout(Some(WS_WAIT))?;
|
||||
let (socket, _) =
|
||||
tungstenite::client(request, stream).map_err(|_| error("protocol handshake failed"))?;
|
||||
let mut subscription = Self { socket };
|
||||
let deadline = Instant::now() + WS_WAIT;
|
||||
loop {
|
||||
match subscription.read(deadline)? {
|
||||
Message::Text(text) => {
|
||||
let raw: serde_json::Value = serde_json::from_str(&text)?;
|
||||
validate_full_snapshot(&raw, expected)?;
|
||||
// Only validated public content is retained, never request headers.
|
||||
std::fs::write(
|
||||
environment.artifacts_dir.join("restored-session.json"),
|
||||
serde_json::to_vec_pretty(&raw)?,
|
||||
)?;
|
||||
return Ok(subscription);
|
||||
}
|
||||
Message::Ping(_) => subscription
|
||||
.socket
|
||||
.flush()
|
||||
.map_err(|_| error("flush protocol Pong"))?,
|
||||
Message::Pong(_) => {}
|
||||
_ => return Err(error("protocol ended before its initial full Snapshot")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prove an already-open subscription remains usable after the other
|
||||
/// environment is stopped and cleaned up. This does not invoke the model.
|
||||
pub fn assert_alive(&mut self) -> Result<()> {
|
||||
let payload = b"restore-e2e-isolation".to_vec();
|
||||
self.socket
|
||||
.send(Message::Ping(payload.clone().into()))
|
||||
.map_err(|_| error("send protocol Ping"))?;
|
||||
let deadline = Instant::now() + WS_WAIT;
|
||||
loop {
|
||||
match self.read(deadline)? {
|
||||
Message::Pong(bytes) if bytes.as_ref() == payload.as_slice() => return Ok(()),
|
||||
Message::Ping(_) => self
|
||||
.socket
|
||||
.flush()
|
||||
.map_err(|_| error("flush protocol Pong"))?,
|
||||
Message::Pong(_) => {}
|
||||
_ => {
|
||||
return Err(error(
|
||||
"idle subscription changed or ended during isolation check",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A public stop must terminate the connected execution, not merely update
|
||||
/// a retained catalog row. Require the shutdown event or websocket close.
|
||||
pub fn expect_stopped(mut self) -> Result<()> {
|
||||
let deadline = Instant::now() + WS_WAIT;
|
||||
loop {
|
||||
match self.read(deadline)? {
|
||||
Message::Close(_) => return Ok(()),
|
||||
Message::Text(text) => match serde_json::from_str::<Event>(&text)? {
|
||||
Event::Shutdown => return Ok(()),
|
||||
Event::WorkerState { .. } => {}
|
||||
_ => return Err(error("unexpected protocol event after Worker stop")),
|
||||
},
|
||||
Message::Ping(_) => self
|
||||
.socket
|
||||
.flush()
|
||||
.map_err(|_| error("flush protocol Pong"))?,
|
||||
Message::Pong(_) => {}
|
||||
_ => return Err(error("unexpected protocol frame after Worker stop")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&mut self, deadline: Instant) -> Result<Message> {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.filter(|duration| !duration.is_zero())
|
||||
.ok_or_else(|| error("protocol deadline exceeded"))?;
|
||||
self.socket.get_mut().set_read_timeout(Some(remaining))?;
|
||||
self.socket
|
||||
.read()
|
||||
.map_err(|_| error("protocol read failed or timed out"))
|
||||
}
|
||||
}
|
||||
|
||||
fn error(message: &str) -> HarnessError {
|
||||
HarnessError::Protocol(message.into())
|
||||
}
|
||||
|
||||
pub fn validate_full_snapshot(raw: &serde_json::Value, expected: &SessionSnapshot) -> Result<()> {
|
||||
let event: Event = serde_json::from_value(raw.clone())?;
|
||||
// Product DTOs intentionally tolerate unknown fields for compatibility. This
|
||||
// fixture boundary does not: anything discarded by typed decoding must never
|
||||
// be persisted to an artifact, including fields outside the Session payload.
|
||||
if serde_json::to_value(&event)? != *raw {
|
||||
return Err(error(
|
||||
"noncanonical or unknown fields in full protocol Snapshot envelope",
|
||||
));
|
||||
}
|
||||
let Event::Snapshot {
|
||||
session,
|
||||
state,
|
||||
in_flight,
|
||||
internal_workers,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return Err(error("first protocol event is not a full Snapshot"));
|
||||
};
|
||||
if state.state != WorkerState::Idle || !in_flight.is_empty() || !internal_workers.is_empty() {
|
||||
return Err(error(
|
||||
"restored Worker is not an idle, completed standalone Session",
|
||||
));
|
||||
}
|
||||
// Typed equality covers entry order/ID/timestamp/provenance/content; raw
|
||||
// equality also refuses unknown fields that could leak private data.
|
||||
if session != *expected
|
||||
|| raw.pointer("/data/session") != Some(&serde_json::to_value(expected)?)
|
||||
{
|
||||
return Err(error(
|
||||
"full Session snapshot differs from supplied expected rows",
|
||||
));
|
||||
}
|
||||
if !session.pending_submissions.submissions.is_empty() {
|
||||
return Err(error("fixture has pending submissions"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use protocol::{
|
||||
Greeting, InFlightSnapshot, Segment, SessionEntryProvenance, SessionSnapshotEntry,
|
||||
SessionSnapshotEntryData, WorkerStateSnapshot, WorkerStatus,
|
||||
};
|
||||
|
||||
// Protocol-validation samples only, not a substitute saved Worker fixture.
|
||||
fn sample() -> (serde_json::Value, SessionSnapshot) {
|
||||
let session = SessionSnapshot {
|
||||
pending_submissions: Default::default(),
|
||||
entries: vec![SessionSnapshotEntry {
|
||||
entry_id: "E-unit-1".into(),
|
||||
timestamp: 123,
|
||||
provenance: SessionEntryProvenance::HumanInput,
|
||||
derived_from: vec![],
|
||||
use_policy: None,
|
||||
data: SessionSnapshotEntryData::UserInput {
|
||||
segments: vec![Segment::text("unit input")],
|
||||
},
|
||||
}],
|
||||
};
|
||||
let event = Event::Snapshot {
|
||||
session: session.clone(),
|
||||
state: WorkerStateSnapshot::from(WorkerStatus::Idle),
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
internal_workers: vec![],
|
||||
greeting: Greeting {
|
||||
worker_name: "unit".into(),
|
||||
cwd: ".".into(),
|
||||
provider: "unit".into(),
|
||||
model: "unit".into(),
|
||||
reasoning: None,
|
||||
scope_summary: "none".into(),
|
||||
tools: vec![],
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
},
|
||||
};
|
||||
(serde_json::to_value(event).unwrap(), session)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_snapshot_requires_exact_ids_timestamps_contents_and_no_unknown_fields() {
|
||||
let (raw, expected) = sample();
|
||||
validate_full_snapshot(&raw, &expected).unwrap();
|
||||
for (key, value) in [
|
||||
("entry_id", serde_json::json!("different")),
|
||||
("timestamp", serde_json::json!(124)),
|
||||
(
|
||||
"hidden_reasoning",
|
||||
serde_json::json!("must never be exposed"),
|
||||
),
|
||||
("segments", serde_json::json!([])),
|
||||
] {
|
||||
let mut changed = raw.clone();
|
||||
changed["data"]["session"]["entries"][0][key] = value;
|
||||
assert!(
|
||||
validate_full_snapshot(&changed, &expected).is_err(),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
let mut missing = raw.clone();
|
||||
missing["data"]["session"]["entries"] = serde_json::json!([]);
|
||||
assert!(validate_full_snapshot(&missing, &expected).is_err());
|
||||
let mut extra = raw;
|
||||
let entry = extra["data"]["session"]["entries"][0].clone();
|
||||
extra["data"]["session"]["entries"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(entry);
|
||||
assert!(validate_full_snapshot(&extra, &expected).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_snapshot_rejects_unknown_fields_outside_session_before_artifact_write() {
|
||||
let (raw, expected) = sample();
|
||||
for pointer in [
|
||||
"",
|
||||
"/data",
|
||||
"/data/greeting",
|
||||
"/data/state",
|
||||
"/data/state/state",
|
||||
] {
|
||||
let mut changed = raw.clone();
|
||||
changed
|
||||
.pointer_mut(pointer)
|
||||
.unwrap()
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert(
|
||||
"hidden_reasoning".into(),
|
||||
serde_json::json!("must not reach artifact"),
|
||||
);
|
||||
assert!(
|
||||
validate_full_snapshot(&changed, &expected).is_err(),
|
||||
"{pointer}"
|
||||
);
|
||||
}
|
||||
let mut changed = raw;
|
||||
changed["data"]["greeting"]["private_key"] = serde_json::json!("must not reach artifact");
|
||||
assert!(validate_full_snapshot(&changed, &expected).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_snapshot_event_cannot_stand_in_for_live_snapshot() {
|
||||
let (_, expected) = sample();
|
||||
assert!(
|
||||
validate_full_snapshot(&serde_json::to_value(Event::Shutdown).unwrap(), &expected)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
|
||||
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
|
||||
//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
|
||||
//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが
|
||||
//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
|
||||
//! 実Server CLIにはstatic assets設定経路がない。明示選択browser_sessionsは本番static shellを
|
||||
//! loopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
|
||||
//! native_migrationは同じshellと実BackendをChromium native WebAuthn/CTAP2へ接続する。
|
||||
//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。
|
||||
|
||||
use std::error::Error;
|
||||
@@ -21,8 +22,8 @@ use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
use server_api::{
|
||||
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest,
|
||||
PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
|
||||
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyAdditionOptionsRequest,
|
||||
PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
|
||||
PasskeyRegistrationOptionsResponse, WhoamiResponse,
|
||||
};
|
||||
#[path = "../src/resident_authenticator.rs"]
|
||||
@@ -30,8 +31,12 @@ mod resident_model;
|
||||
use resident_model::ResidentAuthenticator;
|
||||
use yoi_e2e::WorkerE2eEnvironment;
|
||||
|
||||
#[path = "auth_passkeys/browser_process.rs"]
|
||||
mod browser_process;
|
||||
#[path = "auth_passkeys/browser_sessions.rs"]
|
||||
mod browser_sessions;
|
||||
#[path = "auth_passkeys/native_migration.rs"]
|
||||
mod native_migration;
|
||||
|
||||
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
|
||||
const BROWSER_ORIGIN: &str = "http://localhost:5173";
|
||||
@@ -270,6 +275,29 @@ fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// fixtureも現在の本人/認証bindingをwhoamiから取得する。期待値は対象を選ぶ権限ではない。
|
||||
fn addition_expectation(
|
||||
request: RequestBuilder,
|
||||
artifacts: &Path,
|
||||
) -> Result<PasskeyAdditionOptionsRequest> {
|
||||
let identity: WhoamiResponse = decode(send(
|
||||
request,
|
||||
"read addition owner and authentication reference",
|
||||
artifacts,
|
||||
)?)?;
|
||||
let actor = identity
|
||||
.actor
|
||||
.ok_or("addition fixture requires an authenticated actor")?;
|
||||
Ok(PasskeyAdditionOptionsRequest {
|
||||
expected_user_id: actor.user_id,
|
||||
expected_account_id: actor.account_id,
|
||||
expected_authentication_reference: identity
|
||||
.authentication_reference
|
||||
.ok_or("missing addition authentication reference")?,
|
||||
browser_origin: Some(BROWSER_ORIGIN.to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
fn host_admin_login(
|
||||
environment: &WorkerE2eEnvironment,
|
||||
client: &Client,
|
||||
@@ -291,11 +319,17 @@ fn host_admin_login(
|
||||
let bearer = setup["access_token"]
|
||||
.as_str()
|
||||
.ok_or("host setup omitted initial bearer")?;
|
||||
let addition_body = addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.bearer_auth(bearer),
|
||||
artifacts,
|
||||
)?;
|
||||
let options: PasskeyRegistrationOptionsResponse = decode(send(
|
||||
client
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.bearer_auth(bearer)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_body),
|
||||
"host-created administrator bearer starts actor-derived passkey addition",
|
||||
artifacts,
|
||||
)?)?;
|
||||
@@ -348,6 +382,12 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
policy(&client, base, artifacts, false)?;
|
||||
let mut admin_key = ResidentAuthenticator::new(true);
|
||||
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
||||
let admin_addition = addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.header(COOKIE, &admin_cookie),
|
||||
artifacts,
|
||||
)?;
|
||||
policy(&client, base, artifacts, false)?;
|
||||
set_policy(&client, base, artifacts, &admin_cookie, true)?;
|
||||
let whoami: Value = decode(send(
|
||||
@@ -379,7 +419,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
client
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&admin_addition),
|
||||
"unauthenticated addition options",
|
||||
artifacts,
|
||||
)?,
|
||||
@@ -445,12 +485,18 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
)?)?;
|
||||
assert!(options.public_key.public_key.allow_credentials.is_empty());
|
||||
|
||||
let addition_body = addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.header(COOKIE, &cookie),
|
||||
artifacts,
|
||||
)?;
|
||||
for (field, value) in [
|
||||
("handle", &other.user.handle),
|
||||
("user_id", &other.user.user_id),
|
||||
("account_id", &other.user.account_id),
|
||||
] {
|
||||
let mut body = json!({"browser_origin": BROWSER_ORIGIN});
|
||||
let mut body = serde_json::to_value(&addition_body)?;
|
||||
body[field] = json!(value);
|
||||
rejected(
|
||||
send(
|
||||
@@ -470,7 +516,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
let mut request = client
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, &cookie)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN}));
|
||||
.json(&addition_body);
|
||||
if let Some(origin) = origin {
|
||||
request = request.header(ORIGIN, origin);
|
||||
}
|
||||
@@ -490,7 +536,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, &cookie)
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_body),
|
||||
"authenticated self addition options",
|
||||
artifacts,
|
||||
)?)?;
|
||||
@@ -591,7 +637,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, revoked_cookie)
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_body),
|
||||
"logout prevents further self addition with the old cookie",
|
||||
artifacts,
|
||||
)?,
|
||||
@@ -1797,7 +1843,8 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
||||
client.post(format!("{base}/api/auth/device-login/approve")).header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"user_code": flow["user_code"]})),
|
||||
client.post(format!("{base}/api/auth/passkeys/addition/options")).header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN, "expected_user_id": issue.user.user_id,
|
||||
"expected_account_id": issue.user.account_id, "expected_authentication_reference": "unauthenticated-fixture-not-a-credential"})),
|
||||
];
|
||||
for mut request in requests {
|
||||
if let Some(bearer) = bearer {
|
||||
@@ -1901,7 +1948,12 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, &cookie)
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.header(COOKIE, &cookie),
|
||||
artifacts,
|
||||
)?),
|
||||
"start distinct authenticated self-addition ceremony",
|
||||
artifacts,
|
||||
)?)?;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! browser suiteの子process回収と実行期限を共有する。診断にraw IPCを複製しない。
|
||||
use super::*;
|
||||
use std::process::{Child, ExitStatus};
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub(super) struct BrowserProcess(pub(super) Option<Child>);
|
||||
impl BrowserProcess {
|
||||
pub(super) fn child(&mut self) -> &mut Child {
|
||||
self.0.as_mut().expect("browser process retained")
|
||||
}
|
||||
|
||||
pub(super) fn wait_bounded(mut self, timeout: Duration) -> Result<ExitStatus> {
|
||||
let mut child = self.0.take().expect("browser process retained");
|
||||
let pid = child.id() as libc::pid_t;
|
||||
let (send, receive) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
let _ = send.send(child.wait());
|
||||
});
|
||||
let result = receive.recv_timeout(timeout);
|
||||
if result.is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = waiter.join();
|
||||
Ok(result.map_err(|_| "browser E2E timed out; see sanitized browser diagnostic")??)
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserProcess {
|
||||
fn drop(&mut self) {
|
||||
let Some(mut child) = self.0.take() else {
|
||||
return;
|
||||
};
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
let (send, receive) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
let _ = send.send(child.wait());
|
||||
});
|
||||
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = waiter.join();
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,11 @@
|
||||
//! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite:
|
||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored`
|
||||
//! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。
|
||||
use super::browser_process::BrowserProcess;
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::process::Stdio;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
use yoi_workspace_server::auth::{
|
||||
@@ -62,38 +63,6 @@ fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result<Value>
|
||||
]}))
|
||||
}
|
||||
|
||||
struct BrowserProcess(Option<Child>);
|
||||
impl BrowserProcess {
|
||||
fn child(&mut self) -> &mut Child {
|
||||
self.0.as_mut().expect("browser process retained")
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserProcess {
|
||||
fn drop(&mut self) {
|
||||
let Some(mut child) = self.0.take() else {
|
||||
return;
|
||||
};
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
let (send, receive) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
let _ = send.send(child.wait());
|
||||
});
|
||||
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = waiter.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires production Web assets, Deno and Playwright Chromium"]
|
||||
fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result
|
||||
@@ -132,7 +101,8 @@ fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit
|
||||
drop(reservation);
|
||||
let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?;
|
||||
let mut process = BrowserProcess(Some(
|
||||
Command::new("deno")
|
||||
environment
|
||||
.browser_command(Path::new("deno"))?
|
||||
.current_dir(&root)
|
||||
.args([
|
||||
"run",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 実Backend・本番SPA・Chromium native WebAuthn + CTAP2による旧鍵移行。
|
||||
//! random user handle × resident=false/true × 同じ/別の認証器を検査する。
|
||||
//! 旧鍵fixtureのuser.id/residency指定だけを変更し、実attestationを本番verifier/storeへ送る。
|
||||
//! 現行登録を旧挙動と偽らず、SQL直書き・認証API/mock navigator置換はしない。
|
||||
//! 起動/DB/HOME/XDG/port/回収はWorkerE2eEnvironment、browser profileはその一時領域が所有する。
|
||||
//! DenoとPlaywright Chromium、最新production assets/binariesを呼出側が用意する。
|
||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys native_ctap2 -- --ignored`
|
||||
//! 暗黙buildを避ける実行時は既存providerの3つのbinary overrideを全て指定する。
|
||||
//! DENO_DIR/PLAYWRIGHT_BROWSERS_PATHは事前取得済みtoolの供給元として明示する。
|
||||
//! 物理鍵/実スマホ/provider選択は未確認。CI自動配線の代用にはしない。
|
||||
use super::browser_process::BrowserProcess;
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::process::Stdio;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires production assets/binaries, cached Deno dependencies and Playwright Chromium"]
|
||||
fn native_ctap2_legacy_random_handle_residency_exclusion_and_other_authenticator_migration()
|
||||
-> Result {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()?;
|
||||
let build = root.join("web/workspace/build");
|
||||
if !build.join("index.html").is_file() {
|
||||
return Err("native browser E2E requires an existing production Web build".into());
|
||||
}
|
||||
let mut environment = WorkerE2eEnvironment::new()?;
|
||||
let reservation = TcpListener::bind("127.0.0.1:0")?;
|
||||
let origin = format!("http://localhost:{}", reservation.local_addr()?.port());
|
||||
std::fs::write(
|
||||
&environment.server_config,
|
||||
format!("[browser]\npublic_url = \"{origin}\"\n"),
|
||||
)?;
|
||||
environment.start_empty_backend()?;
|
||||
let admin =
|
||||
environment.setup_hosting_admin(&format!("{}-admin", environment.account_handle), true)?;
|
||||
let bearer = admin["access_token"]
|
||||
.as_str()
|
||||
.ok_or("missing setup token")?;
|
||||
let base = environment
|
||||
.server_url
|
||||
.as_deref()
|
||||
.ok_or("missing Backend URL")?;
|
||||
let response = http_client()?
|
||||
.put(format!("{base}/api/hosting/settings"))
|
||||
.bearer_auth(bearer)
|
||||
.json(&json!({"self_registration_enabled": true}))
|
||||
.send()?;
|
||||
if !response.status().is_success() {
|
||||
return Err("native fixture could not enable synthetic self-registration".into());
|
||||
}
|
||||
// Server public_urlと同一originを使う。API proxyだけでOrigin/Cookieを書き換えない。
|
||||
drop(reservation);
|
||||
let stderr =
|
||||
std::fs::File::create(environment.artifacts_dir.join("native-browser.stderr.log"))?;
|
||||
let mut command = environment.browser_command(Path::new("deno"))?;
|
||||
command
|
||||
.current_dir(&root)
|
||||
.args([
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--no-lock",
|
||||
"--cached-only",
|
||||
"--allow-env",
|
||||
"--allow-net=127.0.0.1,localhost",
|
||||
"--allow-read",
|
||||
"--allow-write",
|
||||
"--allow-run",
|
||||
"--allow-sys",
|
||||
"tests/e2e/browser/native_passkey_migration.ts",
|
||||
base,
|
||||
&origin,
|
||||
build.to_str().ok_or("non-UTF8 build path")?,
|
||||
&environment.account_handle,
|
||||
environment
|
||||
.artifacts_dir
|
||||
.to_str()
|
||||
.ok_or("non-UTF8 artifacts path")?,
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(stderr);
|
||||
let process = BrowserProcess(Some(command.spawn()?));
|
||||
let status = process.wait_bounded(Duration::from_secs(180))?;
|
||||
if !status.success() {
|
||||
return Err(format!(
|
||||
"native CTAP2 matrix failed; sanitized diagnostics: {}",
|
||||
environment
|
||||
.artifacts_dir
|
||||
.join("native-browser.stderr.log")
|
||||
.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let report: Value = serde_json::from_slice(&std::fs::read(
|
||||
environment
|
||||
.artifacts_dir
|
||||
.join("native-migration-result.json"),
|
||||
)?)?;
|
||||
if report["status"] != "passed" || report["rows"].as_array().map(Vec::len) != Some(4) {
|
||||
return Err("native browser did not confirm all four matrix rows".into());
|
||||
}
|
||||
let cleanup = environment.cleanup()?;
|
||||
assert!(
|
||||
cleanup.cleanup_success,
|
||||
"owned native browser environment cleanup failed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -41,13 +41,13 @@ fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_
|
||||
&event.data,
|
||||
&ready_ticket_id,
|
||||
"Planning E2E Ticket A",
|
||||
"planning",
|
||||
"open",
|
||||
)
|
||||
&& has_backend_ticket(
|
||||
&event.data,
|
||||
&planning_ticket_id,
|
||||
"Planning E2E Ticket B",
|
||||
"planning",
|
||||
"open",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
@@ -82,13 +82,13 @@ fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_
|
||||
&event.data,
|
||||
&ready_ticket_id,
|
||||
"Planning E2E Ticket A",
|
||||
"planning",
|
||||
"open",
|
||||
)
|
||||
&& has_backend_ticket(
|
||||
&event.data,
|
||||
&planning_ticket_id,
|
||||
"Planning E2E Ticket B",
|
||||
"planning",
|
||||
"open",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
@@ -137,7 +137,7 @@ fn backend_panel_shell_enter_path_uses_the_same_backend_authority() -> yoi_e2e::
|
||||
&event.data,
|
||||
&ready_ticket_id,
|
||||
"Planning E2E Ticket A",
|
||||
"planning",
|
||||
"open",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Fixed, externally supplied Worker restore. Never generates a fixture or sends model input.
|
||||
#[path = "worker_restore/fixture.rs"]
|
||||
mod fixture;
|
||||
#[path = "worker_restore/seed.rs"]
|
||||
mod seed;
|
||||
|
||||
use std::fs;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use fixture::{Fixture, failure};
|
||||
use seed::Installed;
|
||||
use server_api::{
|
||||
RuntimeWorkerLifecycleResult, RuntimeWorkerShowResponse, WorkerListResponse,
|
||||
WorkerOperationState, WorkerRestoreResponse, WorkerRestoreState, WorkerSummary,
|
||||
};
|
||||
use yoi_e2e::{FixtureSource, Result, WorkerE2eEnvironment, WorkerSessionSubscription};
|
||||
|
||||
#[test]
|
||||
fn supplied_stopped_worker_restores_exact_live_session_and_cleans_up() -> Result<()> {
|
||||
let fixture = Fixture::load()?;
|
||||
let result = (|| {
|
||||
let installed = seed::start(&fixture)?;
|
||||
let subscription = restore(&installed, &fixture, "fixed-fixture-restore-single")?;
|
||||
stop_and_cleanup(installed, vec![subscription], &fixture)
|
||||
})();
|
||||
fixture.source.assert_unchanged()?;
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_fixed_id_restores_keep_sessions_storage_and_cleanup_isolated() -> Result<()> {
|
||||
let fixture = Fixture::load()?;
|
||||
let result = (|| {
|
||||
let (first, second) = std::thread::scope(|scope| {
|
||||
let first = scope.spawn(|| seed::start(&fixture));
|
||||
let second = seed::start(&fixture);
|
||||
(
|
||||
first
|
||||
.join()
|
||||
.map_err(|_| failure("first environment startup panicked"))
|
||||
.and_then(|result| result),
|
||||
second,
|
||||
)
|
||||
});
|
||||
let first = first?;
|
||||
let second = second?;
|
||||
assert_eq!(
|
||||
first.environment.workspace_id,
|
||||
second.environment.workspace_id
|
||||
);
|
||||
assert_eq!(first.environment.runtime_id, second.environment.runtime_id);
|
||||
assert_ne!(first.environment.root, second.environment.root);
|
||||
assert_ne!(
|
||||
first.environment.server_data_dir,
|
||||
second.environment.server_data_dir
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.runtime_data_dir,
|
||||
second.environment.runtime_data_dir
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.artifacts_dir,
|
||||
second.environment.artifacts_dir
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.account_handle,
|
||||
second.environment.account_handle
|
||||
);
|
||||
assert_ne!(first.runtime_public_key, second.runtime_public_key);
|
||||
assert_ne!(first.workspace_public_key, second.workspace_public_key);
|
||||
assert_ne!(
|
||||
first.environment.server_port()?,
|
||||
second.environment.server_port()?
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.runtime_port()?,
|
||||
second.environment.runtime_port()?
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.server_pid(),
|
||||
second.environment.server_pid()
|
||||
);
|
||||
assert_ne!(
|
||||
first.environment.runtime_pid(),
|
||||
second.environment.runtime_pid()
|
||||
);
|
||||
first
|
||||
.environment
|
||||
.assert_authentication_isolated_from(&second.environment)?;
|
||||
second
|
||||
.environment
|
||||
.assert_authentication_isolated_from(&first.environment)?;
|
||||
let (first_subscription, second_subscription) = std::thread::scope(|scope| {
|
||||
// Deliberately reuse request identity in isolated stores, just like fixed IDs.
|
||||
let a = scope.spawn(|| restore(&first, &fixture, "fixed-fixture-restore-concurrent"));
|
||||
let b = restore(&second, &fixture, "fixed-fixture-restore-concurrent");
|
||||
(
|
||||
a.join()
|
||||
.map_err(|_| failure("first restore panicked"))
|
||||
.and_then(|result| result),
|
||||
b,
|
||||
)
|
||||
});
|
||||
let first_subscription = first_subscription?;
|
||||
let mut second_subscription = second_subscription?;
|
||||
let second_session_root = second
|
||||
.environment
|
||||
.runtime_data_dir
|
||||
.join("runtime/workers")
|
||||
.join(&fixture.manifest.worker_id)
|
||||
.join("session");
|
||||
let second_session = FixtureSource::capture(&second_session_root)?;
|
||||
let first_root = first.environment.root.clone();
|
||||
let first_artifacts = first.environment.artifacts_dir.clone();
|
||||
stop_and_cleanup(first, vec![first_subscription], &fixture)?;
|
||||
assert!(!first_root.exists());
|
||||
assert!(first_artifacts.join("cleanup.json").is_file());
|
||||
assert!(second.environment.root.is_dir());
|
||||
assert!(second.environment.server_data_dir.is_dir());
|
||||
assert!(second.environment.runtime_data_dir.is_dir());
|
||||
assert!(
|
||||
second
|
||||
.environment
|
||||
.artifacts_dir
|
||||
.join("restored-session.json")
|
||||
.is_file()
|
||||
);
|
||||
second.environment.assert_connected()?;
|
||||
assert_single_state(&second.environment, &fixture, "idle")?;
|
||||
second_subscription.assert_alive()?;
|
||||
// A second live connection independently verifies content after peer cleanup.
|
||||
let mut reconnected = WorkerSessionSubscription::connect(
|
||||
&second.environment,
|
||||
&fixture.manifest.worker_id,
|
||||
&second.snapshot,
|
||||
)?;
|
||||
reconnected.assert_alive()?;
|
||||
second_session.assert_unchanged()?;
|
||||
stop_and_cleanup(second, vec![second_subscription, reconnected], &fixture)?;
|
||||
Ok(())
|
||||
})();
|
||||
fixture.source.assert_unchanged()?;
|
||||
result
|
||||
}
|
||||
|
||||
fn restore(
|
||||
installed: &Installed,
|
||||
fixture: &Fixture,
|
||||
request_id: &str,
|
||||
) -> Result<WorkerSessionSubscription> {
|
||||
phase(&installed.environment, "observe-stopped")?;
|
||||
let stopped = assert_single_state(&installed.environment, fixture, "stopped")?;
|
||||
let token = stopped
|
||||
.restore_observation_token
|
||||
.as_deref()
|
||||
.filter(|token| !token.is_empty())
|
||||
.ok_or_else(|| failure("stopped Worker did not expose a restore observation token"))?;
|
||||
phase(&installed.environment, "restore")?;
|
||||
let result: WorkerRestoreResponse = serde_json::from_value(
|
||||
installed
|
||||
.environment
|
||||
.restore_worker(&fixture.manifest.worker_id, request_id, token)?,
|
||||
)?;
|
||||
if result.workspace_id != fixture.manifest.workspace_id
|
||||
|| result.runtime_id != fixture.manifest.runtime_id
|
||||
|| result.worker_id != fixture.manifest.worker_id
|
||||
|| result.result.state != WorkerRestoreState::Accepted
|
||||
{
|
||||
return Err(failure(format!(
|
||||
"restore did not commit accepted: {:?}",
|
||||
result.result.state
|
||||
)));
|
||||
}
|
||||
assert_single_state(&installed.environment, fixture, "idle")?;
|
||||
let detail: RuntimeWorkerShowResponse = serde_json::from_value(
|
||||
installed
|
||||
.environment
|
||||
.worker_detail(&fixture.manifest.worker_id)?,
|
||||
)?;
|
||||
if detail.worker.worker_id != fixture.manifest.worker_id
|
||||
|| detail.worker.runtime_id != fixture.manifest.runtime_id
|
||||
|| detail.worker.state != "idle"
|
||||
|| detail
|
||||
.worker
|
||||
.worker_state
|
||||
.as_ref()
|
||||
.is_none_or(|state| state.state != protocol::WorkerState::Idle)
|
||||
{
|
||||
return Err(failure(
|
||||
"restored Worker detail is not the single live idle execution",
|
||||
));
|
||||
}
|
||||
phase(&installed.environment, "subscribe-live-session")?;
|
||||
let mut subscription = WorkerSessionSubscription::connect(
|
||||
&installed.environment,
|
||||
&fixture.manifest.worker_id,
|
||||
&installed.snapshot,
|
||||
)?;
|
||||
subscription.assert_alive()?;
|
||||
phase(&installed.environment, "live-session-verified")?;
|
||||
Ok(subscription)
|
||||
}
|
||||
|
||||
fn assert_single_state(
|
||||
environment: &WorkerE2eEnvironment,
|
||||
fixture: &Fixture,
|
||||
state: &str,
|
||||
) -> Result<WorkerSummary> {
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
loop {
|
||||
let list: WorkerListResponse = serde_json::from_value(environment.list_workers()?)?;
|
||||
if list.workspace_id != fixture.manifest.workspace_id || list.items.len() > 1 {
|
||||
return Err(failure(
|
||||
"Workspace Worker list has wrong identity or duplicate Workers",
|
||||
));
|
||||
}
|
||||
if let Some(worker) = list.items.into_iter().next() {
|
||||
if worker.worker_id != fixture.manifest.worker_id
|
||||
|| worker.runtime_id != fixture.manifest.runtime_id
|
||||
{
|
||||
return Err(failure("Workspace listed an unexpected Worker identity"));
|
||||
}
|
||||
if worker.state == state {
|
||||
return Ok(worker);
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(failure(format!(
|
||||
"Worker list did not become exactly one {state} Worker"
|
||||
)));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_and_cleanup(
|
||||
installed: Installed,
|
||||
subscriptions: Vec<WorkerSessionSubscription>,
|
||||
fixture: &Fixture,
|
||||
) -> Result<()> {
|
||||
phase(&installed.environment, "stop")?;
|
||||
let result: RuntimeWorkerLifecycleResult = serde_json::from_value(
|
||||
installed
|
||||
.environment
|
||||
.stop_worker(&fixture.manifest.worker_id)?,
|
||||
)?;
|
||||
if result.state != WorkerOperationState::Accepted {
|
||||
return Err(failure("Worker stop was not accepted"));
|
||||
}
|
||||
installed.environment.assert_connected()?;
|
||||
for subscription in subscriptions {
|
||||
subscription.expect_stopped()?;
|
||||
// A transport close caused by Server/Runtime shutdown is not stop evidence.
|
||||
installed.environment.assert_connected()?;
|
||||
}
|
||||
assert_single_state(&installed.environment, fixture, "stopped")?;
|
||||
phase(&installed.environment, "shutdown-cleanup")?;
|
||||
let report = installed.environment.cleanup()?;
|
||||
if !report.cleanup_success || report.fixture_root_exists_after {
|
||||
return Err(failure("environment cleanup failed"));
|
||||
}
|
||||
if !report.report_path.is_file() {
|
||||
return Err(failure("cleanup evidence is missing"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn phase(environment: &WorkerE2eEnvironment, phase: &str) -> Result<()> {
|
||||
fs::write(
|
||||
environment.artifacts_dir.join("restore-phase.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({"phase":phase}))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use protocol::{SessionMessageRole, SessionSnapshot, SessionSnapshotEntryData};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use worker_runtime::test_support::StoppedWorkerTemplate;
|
||||
use yoi_e2e::{FixtureSource, HarnessError, Result};
|
||||
|
||||
pub const PROFILE: &str = "project:fixture-chat";
|
||||
const ASSETS: [&str; 3] = [
|
||||
"expected.json",
|
||||
"worker-template.json",
|
||||
"workspace-config.dcdl",
|
||||
];
|
||||
const MAX_ASSET_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub format_version: u32,
|
||||
pub source_revision: String,
|
||||
pub runtime_schema_version: u32,
|
||||
pub session_schema_version: u32,
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub session_id: String,
|
||||
pub segment_id: String,
|
||||
pub model: String,
|
||||
pub state: String,
|
||||
pub acquisition: String,
|
||||
pub source_file_sha256: BTreeMap<String, String>,
|
||||
pub files: BTreeMap<String, Asset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Asset {
|
||||
pub bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Expected {
|
||||
source_revision: String,
|
||||
model: String,
|
||||
worker_id: String,
|
||||
session_id: String,
|
||||
rows: Vec<Value>,
|
||||
}
|
||||
|
||||
pub struct Fixture {
|
||||
pub root: PathBuf,
|
||||
pub source: FixtureSource,
|
||||
pub manifest: Manifest,
|
||||
expected: Expected,
|
||||
}
|
||||
|
||||
pub fn failure(message: impl Into<String>) -> HarnessError {
|
||||
HarnessError::Protocol(message.into())
|
||||
}
|
||||
pub fn support<T, E: std::fmt::Display>(result: std::result::Result<T, E>) -> Result<T> {
|
||||
result.map_err(|error| failure(error.to_string()))
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn load() -> Result<Self> {
|
||||
// Candidate override is explicit and read-only. Missing assets are failures,
|
||||
// never skips, and no test generates or repairs a fixture.
|
||||
let root = std::env::var_os("YOI_E2E_RESTORE_FIXTURE")
|
||||
.map(PathBuf::from)
|
||||
.map(|path| {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join(path)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/worker-codex-v1")
|
||||
});
|
||||
let manifest: Manifest =
|
||||
serde_json::from_slice(&bounded_read(&root.join("manifest.json"))?)?;
|
||||
if manifest.format_version != 1
|
||||
|| manifest.state != "stopped"
|
||||
|| manifest.runtime_schema_version == 0
|
||||
|| manifest.session_schema_version == 0
|
||||
|| manifest.acquisition.is_empty()
|
||||
|| manifest.source_file_sha256.is_empty()
|
||||
|| manifest.source_revision.len() != 40
|
||||
|| !manifest
|
||||
.source_revision
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(failure("unsupported/incomplete fixture manifest"));
|
||||
}
|
||||
let names: BTreeSet<_> = manifest.files.keys().map(String::as_str).collect();
|
||||
if names != BTreeSet::from(ASSETS) {
|
||||
return Err(failure("unexpected fixture asset set"));
|
||||
}
|
||||
for name in ASSETS {
|
||||
bounded_read(&root.join(name))?;
|
||||
}
|
||||
let source = FixtureSource::capture(&root)?;
|
||||
if source.hashes().len() != 4 {
|
||||
return Err(failure("fixture has unmanifested files"));
|
||||
}
|
||||
for (name, asset) in &manifest.files {
|
||||
if asset.bytes != fs::metadata(root.join(name))?.len()
|
||||
|| source.hashes().get(Path::new(name)) != Some(&format!("sha256:{}", asset.sha256))
|
||||
{
|
||||
return Err(failure(format!("fixture asset hash/size mismatch: {name}")));
|
||||
}
|
||||
}
|
||||
let expected: Expected =
|
||||
serde_json::from_slice(&bounded_read(&root.join("expected.json"))?)?;
|
||||
if expected.source_revision != manifest.source_revision
|
||||
|| expected.model != manifest.model
|
||||
|| expected.worker_id != manifest.worker_id
|
||||
|| expected.session_id != manifest.session_id
|
||||
|| expected.rows.is_empty()
|
||||
{
|
||||
return Err(failure(
|
||||
"expected conversation identity differs from manifest",
|
||||
));
|
||||
}
|
||||
verify_builtin_revision(&manifest.source_revision)?;
|
||||
let fixture = Self {
|
||||
root,
|
||||
source,
|
||||
manifest,
|
||||
expected,
|
||||
};
|
||||
fixture.template(&fixture.root)?;
|
||||
Ok(fixture)
|
||||
}
|
||||
|
||||
pub fn template(&self, copy: &Path) -> Result<StoppedWorkerTemplate> {
|
||||
let bytes = bounded_read(©.join("worker-template.json"))?;
|
||||
let template: StoppedWorkerTemplate = serde_json::from_slice(&bytes)?;
|
||||
let shape: Value = serde_json::from_slice(&bytes)?;
|
||||
let session_manifest_bytes: Vec<u8> =
|
||||
serde_json::from_value(shape["session_manifest"].clone())?;
|
||||
let segment_bytes: Vec<u8> = serde_json::from_value(shape["segment_jsonl"].clone())?;
|
||||
for (name, bytes) in [
|
||||
(
|
||||
"session/session.json".to_owned(),
|
||||
session_manifest_bytes.as_slice(),
|
||||
),
|
||||
(
|
||||
format!("session/segments/{}.jsonl", self.manifest.segment_id),
|
||||
segment_bytes.as_slice(),
|
||||
),
|
||||
] {
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
if self.manifest.source_file_sha256.get(&name) != Some(&digest) {
|
||||
return Err(failure(
|
||||
"exported Session bytes differ from acquisition hash evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
let session_manifest: Value = serde_json::from_slice(&session_manifest_bytes)?;
|
||||
let m = &self.manifest;
|
||||
if template.workspace_id() != m.workspace_id
|
||||
|| template.runtime_id() != m.runtime_id
|
||||
|| template.worker_id().to_string() != m.worker_id
|
||||
|| template.session_id().to_string() != m.session_id
|
||||
|| template.segment_id().to_string() != m.segment_id
|
||||
|| shape["runtime_schema_version"] != m.runtime_schema_version
|
||||
|| session_manifest["schema_version"] != m.session_schema_version
|
||||
{
|
||||
return Err(failure("template schema/identity differs from manifest"));
|
||||
}
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
pub fn verify_conversation(&self, snapshot: &SessionSnapshot) -> Result<()> {
|
||||
let mut rows = Vec::new();
|
||||
for entry in &snapshot.entries {
|
||||
let (role, parts) = match &entry.data {
|
||||
SessionSnapshotEntryData::UserInput { segments } => {
|
||||
("user", serde_json::to_value(segments)?)
|
||||
}
|
||||
SessionSnapshotEntryData::Message {
|
||||
role: SessionMessageRole::Assistant,
|
||||
content,
|
||||
} => ("assistant", serde_json::to_value(content)?),
|
||||
// Display-only accounting is not a conversation row. Its exact
|
||||
// IDs, timestamp and values are still checked in the full WS snapshot.
|
||||
SessionSnapshotEntryData::RunStats { .. } => continue,
|
||||
_ => {
|
||||
return Err(failure(
|
||||
"unexpected non-conversation entry in full Session snapshot",
|
||||
));
|
||||
}
|
||||
};
|
||||
rows.push(json!({"entry_id":entry.entry_id,"timestamp":entry.timestamp,"role":role,"parts":parts}));
|
||||
}
|
||||
if rows != self.expected.rows {
|
||||
return Err(failure(
|
||||
"full public conversation rows differ from expected IDs/time/roles/text/order",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_read(path: &Path) -> Result<Vec<u8>> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||||
failure(format!(
|
||||
"required fixture asset {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_ASSET_BYTES {
|
||||
return Err(failure("fixture asset is not a bounded regular file"));
|
||||
}
|
||||
Ok(fs::read(path)?)
|
||||
}
|
||||
|
||||
fn verify_builtin_revision(revision: &str) -> Result<()> {
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap();
|
||||
// Resolve the immutable commit before diff; bad revisions must not be treated
|
||||
// as an empty diff. All resources are checked, including transitive imports.
|
||||
let commit = Command::new("git")
|
||||
.args(["cat-file", "-t", revision])
|
||||
.current_dir(workspace)
|
||||
.output()?;
|
||||
if !commit.status.success() || commit.stdout != b"commit\n" {
|
||||
return Err(failure(
|
||||
"fixture source revision is unavailable for builtin verification",
|
||||
));
|
||||
}
|
||||
let status = Command::new("git")
|
||||
.args([
|
||||
"diff",
|
||||
"--quiet",
|
||||
revision,
|
||||
"--",
|
||||
"resources",
|
||||
"crates/builtin-source",
|
||||
])
|
||||
.current_dir(workspace)
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
return Err(failure(
|
||||
"builtin/config resources differ from fixture source revision; provide a new version or frozen references",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
use std::fs;
|
||||
use std::net::TcpListener;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use config_source::{ConfigTreeChange, VirtualPath};
|
||||
use protocol::SessionSnapshot;
|
||||
use serde_json::Value;
|
||||
use worker_runtime::FsRuntimeStoreOptions;
|
||||
use worker_runtime::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource,
|
||||
WorkspaceApiRef,
|
||||
};
|
||||
use yoi_e2e::{Result, WorkerE2eEnvironment};
|
||||
use yoi_workspace_server::auth::{mint_secret, new_id, now_rfc3339, token_hash};
|
||||
use yoi_workspace_server::config_source::{ConfigCommitRequest, WorkspaceConfigSchemaRegistry};
|
||||
use yoi_workspace_server::hosts::{
|
||||
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest,
|
||||
};
|
||||
use yoi_workspace_server::profile_settings::{
|
||||
ProfileConfigSchemaProvider, build_virtual_profile_config_bundle,
|
||||
project_profiles_from_workspace_config,
|
||||
};
|
||||
use yoi_workspace_server::store::{
|
||||
AccountRecord, ApiTokenRecord, UserRecord, WorkspaceBootstrapRecord,
|
||||
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
|
||||
};
|
||||
use yoi_workspace_server::test_support::{FixedWorkerSeed, seed_fixed_worker};
|
||||
use yoi_workspace_server::workspace_signing_identity::{
|
||||
FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService,
|
||||
workspace_signing_material_root,
|
||||
};
|
||||
use yoi_workspace_server::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
||||
|
||||
use super::fixture::{Fixture, PROFILE, failure, support};
|
||||
|
||||
pub struct Installed {
|
||||
pub environment: WorkerE2eEnvironment,
|
||||
pub snapshot: SessionSnapshot,
|
||||
pub runtime_public_key: String,
|
||||
pub workspace_public_key: String,
|
||||
}
|
||||
|
||||
pub fn start(fixture: &Fixture) -> Result<Installed> {
|
||||
let mut environment = WorkerE2eEnvironment::new()?;
|
||||
super::phase(&environment, "fixture-copy-verify")?;
|
||||
let copy = environment.root.join("provided-fixture");
|
||||
fixture.source.copy_to(©)?;
|
||||
fs::write(
|
||||
environment.artifacts_dir.join("fixture-manifest.json"),
|
||||
fs::read(copy.join("manifest.json"))?,
|
||||
)?;
|
||||
let template = fixture.template(©)?;
|
||||
let runtime_identity =
|
||||
environment.initialize_runtime_identity(fixture.manifest.runtime_id.clone())?;
|
||||
let runtime_public_key = runtime_identity["public_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| failure("Runtime public bundle omitted key"))?
|
||||
.to_owned();
|
||||
|
||||
// Reserve an environment-local placeholder endpoint only while the Runtime is
|
||||
// offline. Its real :0 endpoint replaces this binding through public API CAS.
|
||||
let reserved_endpoint = TcpListener::bind("127.0.0.1:0")?;
|
||||
super::phase(
|
||||
&environment,
|
||||
"offline-backend-bootstrap-profile-worker-seed",
|
||||
)?;
|
||||
let bootstrap = bootstrap(
|
||||
&environment,
|
||||
fixture,
|
||||
©,
|
||||
&runtime_identity,
|
||||
&format!("http://{}", reserved_endpoint.local_addr()?),
|
||||
&template.worker_name(),
|
||||
)?;
|
||||
// Close the placeholder before Backend probes it: an accepting TCP listener
|
||||
// with no HTTP service would stall discovery until its transport timeout.
|
||||
drop(reserved_endpoint);
|
||||
super::phase(&environment, "start-seeded-backend")?;
|
||||
environment.start_seeded_backend(
|
||||
fixture.manifest.workspace_id.clone(),
|
||||
fixture.manifest.runtime_id.clone(),
|
||||
bootstrap.token,
|
||||
)?;
|
||||
let mut snapshot = None;
|
||||
super::phase(&environment, "initialize-runtime-trust")?;
|
||||
environment.start_runtime_with_seed(|environment| {
|
||||
super::phase(environment, "install-runtime-fixture-and-validate-session")?;
|
||||
let options = FsRuntimeStoreOptions::new(environment.runtime_data_dir.join("runtime"))
|
||||
.with_runtime_id(&fixture.manifest.runtime_id);
|
||||
let request = CreateWorkerRequest {
|
||||
worker_id: template.worker_id(),
|
||||
create_fingerprint: bootstrap.create_fingerprint.clone(),
|
||||
profile: ProfileSelector::Named(PROFILE.into()),
|
||||
display_name: Some("Restore E2E fixture".into()),
|
||||
profile_source: ProfileSourceArchiveSource::WorkspaceConfig {
|
||||
archive: bootstrap.archive.clone(),
|
||||
},
|
||||
config_bundle: Some(bootstrap.config_bundle.clone()),
|
||||
workspace_api: Some(WorkspaceApiRef {
|
||||
workspace_id: fixture.manifest.workspace_id.clone(),
|
||||
base_url: environment.backend_target()?.0.into(),
|
||||
}),
|
||||
initial_input: None,
|
||||
workdir_attachment_requests: vec![],
|
||||
workdir_attachments: vec![],
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: vec![],
|
||||
memory_settings: Some(bootstrap.memory_settings.clone()),
|
||||
subjektiv_attached: false,
|
||||
backend_job: None,
|
||||
};
|
||||
support(template.install(&options, request, &bootstrap.manifest))?;
|
||||
let sessions = support(session_store::WorkerSessionStore::open_read_only(
|
||||
options
|
||||
.root
|
||||
.join("workers")
|
||||
.join(template.worker_id().to_string())
|
||||
.join("session"),
|
||||
))?;
|
||||
let (logs, _) = support(sessions.read_all_read_only_bounded(
|
||||
template.session_id(),
|
||||
template.segment_id(),
|
||||
8 * 1024 * 1024,
|
||||
))?;
|
||||
let projected = session_store::project_session_snapshot(template.session_id(), &logs);
|
||||
fixture.verify_conversation(&projected)?;
|
||||
snapshot = Some(full_restored_snapshot(&sessions, &logs));
|
||||
Ok(())
|
||||
})?;
|
||||
fixture.source.assert_unchanged()?;
|
||||
Ok(Installed {
|
||||
environment,
|
||||
snapshot: snapshot.ok_or_else(|| failure("Runtime seed was not invoked"))?,
|
||||
runtime_public_key,
|
||||
workspace_public_key: bootstrap.workspace_public_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn full_restored_snapshot(
|
||||
store: &dyn session_store::Store,
|
||||
logs: &[session_store::LogEntry],
|
||||
) -> SessionSnapshot {
|
||||
session_store::public_snapshot::project_current_session_snapshot_with_accounting(store, logs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use protocol::{
|
||||
Event, Greeting, InFlightSnapshot, InvokeKind, SessionSnapshotEntryData,
|
||||
WorkerStateSnapshot, WorkerStatus,
|
||||
};
|
||||
use session_store::{
|
||||
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
|
||||
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn completed_run_full_snapshot_retains_invoke_and_usage_accounting() -> Result<()> {
|
||||
// Projection regression only: no saved Worker or substitute E2E fixture.
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store = support(session_store::WorkerSessionStore::new(temp.path()))?;
|
||||
let session_id = session_store::new_session_id();
|
||||
let logs = vec![
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: Default::default(),
|
||||
history: vec![],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::Invoke {
|
||||
ts: 10,
|
||||
trigger: InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 20,
|
||||
entry: LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::Assistant,
|
||||
content: vec![LoggedContentPart::Text {
|
||||
text: "unit response".into(),
|
||||
}],
|
||||
},
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId("unit-response".into()),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
input_metadata: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
LogEntry::LlmUsage {
|
||||
ts: 30,
|
||||
history_len: 1,
|
||||
input_total_tokens: 100,
|
||||
cache_read_tokens: 20,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 5,
|
||||
},
|
||||
serde_json::from_value(serde_json::json!({"kind":"run_completed", "ts":40,
|
||||
"interrupted":false,"result":"finished"}))?,
|
||||
];
|
||||
let conversation = session_store::project_session_snapshot(session_id, &logs);
|
||||
let expected = full_restored_snapshot(&store, &logs);
|
||||
assert_eq!(expected.entries.len(), conversation.entries.len() + 2);
|
||||
assert!(matches!(
|
||||
expected.entries[expected.entries.len() - 2].data,
|
||||
SessionSnapshotEntryData::Invoke {
|
||||
trigger: InvokeKind::UserSend
|
||||
}
|
||||
));
|
||||
assert_eq!(expected.entries[expected.entries.len() - 2].timestamp, 10);
|
||||
assert!(matches!(
|
||||
expected.entries.last().unwrap().data,
|
||||
SessionSnapshotEntryData::Usage {
|
||||
input_tokens: 100,
|
||||
cache_read_input_tokens: 20,
|
||||
output_tokens: 5
|
||||
}
|
||||
));
|
||||
assert_eq!(expected.entries.last().unwrap().timestamp, 30);
|
||||
let live = session_store::public_snapshot::project_current_session_snapshot_with_accounting(
|
||||
&store, &logs,
|
||||
);
|
||||
let event = Event::Snapshot {
|
||||
session: live,
|
||||
state: WorkerStateSnapshot::from(WorkerStatus::Idle),
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
internal_workers: vec![],
|
||||
greeting: Greeting {
|
||||
worker_name: "unit".into(),
|
||||
cwd: ".".into(),
|
||||
provider: "unit".into(),
|
||||
model: "unit".into(),
|
||||
reasoning: None,
|
||||
scope_summary: "none".into(),
|
||||
tools: vec![],
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
},
|
||||
};
|
||||
let raw = serde_json::to_value(event)?;
|
||||
yoi_e2e::validate_full_snapshot(&raw, &expected)?;
|
||||
assert!(yoi_e2e::validate_full_snapshot(&raw, &conversation).is_err());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Bootstrap {
|
||||
token: String,
|
||||
workspace_public_key: String,
|
||||
create_fingerprint: String,
|
||||
archive: worker_runtime::profile_archive::ProfileSourceArchiveRef,
|
||||
config_bundle: ConfigBundleRef,
|
||||
manifest: manifest::WorkerManifest,
|
||||
memory_settings: manifest::WorkspaceMemorySettingsSnapshot,
|
||||
}
|
||||
|
||||
fn bootstrap(
|
||||
environment: &WorkerE2eEnvironment,
|
||||
fixture: &Fixture,
|
||||
copy: &Path,
|
||||
runtime_identity: &Value,
|
||||
endpoint: &str,
|
||||
worker_name: &str,
|
||||
) -> Result<Bootstrap> {
|
||||
fs::create_dir_all(&environment.server_data_dir)?;
|
||||
let database = environment.server_data_dir.join("server.db");
|
||||
if database.exists() {
|
||||
return Err(failure("Backend seed refuses an existing database"));
|
||||
}
|
||||
let store = support(SqliteWorkspaceStore::open(&database))?;
|
||||
let now = now_rfc3339();
|
||||
let account_id = new_id("acct-user");
|
||||
let user_id = new_id("user");
|
||||
support(store.upsert_account(&AccountRecord {
|
||||
account_id: account_id.clone(),
|
||||
kind: "user".into(),
|
||||
handle: environment.account_handle.clone(),
|
||||
display_name: "Restore E2E owner".into(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
}))?;
|
||||
support(store.upsert_user(&UserRecord {
|
||||
user_id: user_id.clone(),
|
||||
account_id: account_id.clone(),
|
||||
handle: environment.account_handle.clone(),
|
||||
display_name: "Restore E2E owner".into(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
}))?;
|
||||
let token = mint_secret("yoi_api");
|
||||
support(store.create_api_token(&ApiTokenRecord {
|
||||
token_id: new_id("api-token"),
|
||||
token_hash: token_hash(&token),
|
||||
user_id,
|
||||
label: "restore-e2e".into(),
|
||||
created_at: now.clone(),
|
||||
expires_at: None,
|
||||
revoked_at: None,
|
||||
last_used_at: None,
|
||||
}))?;
|
||||
let identities = WorkspaceSigningIdentityService::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(FsWorkspaceSigningMaterialStore::new(
|
||||
workspace_signing_material_root(&database),
|
||||
)),
|
||||
);
|
||||
let (identity, operation) = support(identities.prepare_workspace_creation(
|
||||
"restore-e2e-bootstrap",
|
||||
"restore-e2e-bootstrap",
|
||||
&fixture.manifest.workspace_id,
|
||||
&account_id,
|
||||
))?;
|
||||
let workspace_public_key = identity.public_key.clone();
|
||||
support(store.create_workspace_bootstrap(
|
||||
&WorkspaceBootstrapRecord {
|
||||
operation_key: "restore-e2e-bootstrap".into(),
|
||||
request_fingerprint: "restore-e2e-bootstrap".into(),
|
||||
workspace: WorkspaceRecord {
|
||||
workspace_id: fixture.manifest.workspace_id.clone(),
|
||||
owner_account_id: account_id,
|
||||
display_name: "Restore E2E".into(),
|
||||
state: "active".into(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
},
|
||||
repository: None,
|
||||
},
|
||||
&identity,
|
||||
&operation,
|
||||
))?;
|
||||
support(
|
||||
store.upsert_workspace_runtime_binding(
|
||||
WorkspaceRuntimeBinding {
|
||||
workspace_id: fixture.manifest.workspace_id.clone(),
|
||||
runtime_id: fixture.manifest.runtime_id.clone(),
|
||||
display_name: format!("E2E {}", fixture.manifest.runtime_id),
|
||||
base_url: endpoint.into(),
|
||||
public_key: runtime_identity["public_key"]
|
||||
.as_str()
|
||||
.ok_or_else(|| failure("missing Runtime public key"))?
|
||||
.into(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_id: String::new(),
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
revoked_at: None,
|
||||
},
|
||||
false,
|
||||
),
|
||||
)?;
|
||||
|
||||
let schema = support(
|
||||
WorkspaceConfigSchemaRegistry::default()
|
||||
.with_provider(Arc::new(ProfileConfigSchemaProvider))
|
||||
.with_provider(Arc::new(
|
||||
yoi_workspace_server::prompt_settings::PromptConfigSchemaProvider,
|
||||
))
|
||||
.with_provider(Arc::new(
|
||||
yoi_workspace_server::runtime_settings::RuntimeConfigSchemaProvider,
|
||||
))
|
||||
.with_provider(Arc::new(
|
||||
yoi_workspace_server::repository_access::RepositoryAccessConfigSchemaProvider,
|
||||
))
|
||||
.with_provider(Arc::new(
|
||||
yoi_workspace_server::skills::SkillConfigSchemaProvider,
|
||||
))
|
||||
.compose(),
|
||||
)?;
|
||||
let state = support(store.ensure_workspace_config_materialized_with_schema(
|
||||
&fixture.manifest.workspace_id,
|
||||
&now,
|
||||
schema.clone(),
|
||||
))?;
|
||||
let main = support(VirtualPath::parse("main.dcdl"))?;
|
||||
let current = state
|
||||
.snapshot
|
||||
.get(&main)
|
||||
.ok_or_else(|| failure("Workspace config omitted main.dcdl"))?;
|
||||
let candidate = support(store.evaluate_workspace_config_candidate_with_schema(
|
||||
&fixture.manifest.workspace_id,
|
||||
&ConfigCommitRequest {
|
||||
base_digest: state.snapshot.digest.clone(),
|
||||
changes: vec![ConfigTreeChange::Update {
|
||||
path: main.clone(),
|
||||
expected_digest: current.content_digest.clone(),
|
||||
content: fs::read_to_string(copy.join("workspace-config.dcdl"))?,
|
||||
}],
|
||||
entrypoints: vec![main],
|
||||
},
|
||||
schema,
|
||||
))?;
|
||||
let state = support(
|
||||
store.commit_evaluated_workspace_config(&fixture.manifest.workspace_id, &candidate),
|
||||
)?;
|
||||
let projection = support(project_profiles_from_workspace_config(
|
||||
&fixture.manifest.workspace_id,
|
||||
&state,
|
||||
))?;
|
||||
let bundle = support(build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&state,
|
||||
&fixture.manifest.workspace_id,
|
||||
&now,
|
||||
PROFILE,
|
||||
))?
|
||||
.ok_or_else(|| failure("fixture Profile is not registered"))?;
|
||||
let archive = bundle
|
||||
.profile_source_archive
|
||||
.as_ref()
|
||||
.ok_or_else(|| failure("fixture Profile archive is absent"))?;
|
||||
let mut resolved = support(
|
||||
support(archive.verify())?.resolve_profile(
|
||||
PROFILE,
|
||||
&environment
|
||||
.runtime_data_dir
|
||||
.join("runtime/workers")
|
||||
.join(&fixture.manifest.worker_id),
|
||||
worker_name,
|
||||
),
|
||||
)?;
|
||||
if resolved.model.ref_.as_deref() != Some(fixture.manifest.model.as_str()) {
|
||||
return Err(failure("resolved Profile changed the supplied model"));
|
||||
}
|
||||
let request = WorkerSpawnRequest {
|
||||
intent: WorkerSpawnIntent::WorkspaceCoding,
|
||||
requested_worker_name: Some("Restore E2E fixture".into()),
|
||||
singleton_key: None,
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
|
||||
profile: ProfileSelector::Named(PROFILE.into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: vec![],
|
||||
workdir_attachment_requests: vec![],
|
||||
resolved_workdir_attachment_requests: vec![],
|
||||
resolved_workdir_attachments: vec![],
|
||||
resolved_config_bundle: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: vec![],
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
};
|
||||
let reservation = support(seed_fixed_worker(
|
||||
&store,
|
||||
FixedWorkerSeed {
|
||||
workspace_id: &fixture.manifest.workspace_id,
|
||||
runtime_id: &fixture.manifest.runtime_id,
|
||||
worker_id: support(fixture.manifest.worker_id.parse())?,
|
||||
allocation_key: "restore-e2e-worker",
|
||||
request: &request,
|
||||
},
|
||||
))?;
|
||||
if resolved.feature.memory.profile.enabled {
|
||||
support(
|
||||
resolved
|
||||
.feature
|
||||
.memory
|
||||
.bind_workspace_settings(reservation.memory_settings.clone()),
|
||||
)?;
|
||||
}
|
||||
Ok(Bootstrap {
|
||||
manifest: resolved,
|
||||
memory_settings: reservation.memory_settings,
|
||||
token,
|
||||
workspace_public_key,
|
||||
create_fingerprint: reservation.create_fingerprint,
|
||||
archive: archive.reference.clone(),
|
||||
config_bundle: ConfigBundleRef {
|
||||
id: bundle.metadata.id,
|
||||
digest: bundle.metadata.digest,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -21,8 +21,8 @@
|
||||
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
|
||||
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
|
||||
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check",
|
||||
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts",
|
||||
"test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts"
|
||||
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts test/auth-browser/passkey-state.browser.test.ts",
|
||||
"test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts test/auth-browser/sidebar-shell.browser.test.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0",
|
||||
|
||||
@@ -21,13 +21,33 @@ export type RequestActor = { user_id: string, account_id: string, handle: string
|
||||
|
||||
export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", };
|
||||
|
||||
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, };
|
||||
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState,
|
||||
/**
|
||||
* Non-secret comparison evidence, never an authentication credential.
|
||||
*/
|
||||
authentication_reference: string | null, };
|
||||
|
||||
export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, };
|
||||
|
||||
export type AuthUserResponse = { user: AuthenticatedUser, };
|
||||
export type AuthUserResponse = { user: AuthenticatedUser,
|
||||
/**
|
||||
* Comparison evidence for the exact newly issued session, not a credential.
|
||||
*/
|
||||
authentication_reference: string, };
|
||||
|
||||
export type PasskeyAdditionOptionsRequest = { browser_origin?: string | null, };
|
||||
export type PasskeyAdditionOptionsRequest = { expected_user_id: string, expected_account_id: string, expected_authentication_reference: string, browser_origin?: string | null, };
|
||||
|
||||
export type PasskeySummary = { key_reference: string, created_at: string, last_used_at: string | null, normal_login_verified_at: string | null, };
|
||||
|
||||
export type PasskeyListResponse = { user: AuthenticatedUser, keys: Array<PasskeySummary>, };
|
||||
|
||||
export type PasskeyAdditionResultRequest = { challenge_id: string, expected_user_id: string, expected_account_id: string,
|
||||
/**
|
||||
* Original options authentication reference; the current same-owner session may differ.
|
||||
*/
|
||||
expected_authentication_reference: string, };
|
||||
|
||||
export type PasskeyAdditionResultResponse = { "status": "unknown", } | { "status": "saved", user: AuthenticatedUser, key: PasskeySummary, };
|
||||
|
||||
export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, };
|
||||
|
||||
|
||||
@@ -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:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
|
||||
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
|
||||
// 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
|
||||
|
||||
@@ -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:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
|
||||
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
|
||||
// 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:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
|
||||
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
|
||||
// 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:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
|
||||
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
|
||||
// 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:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
|
||||
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
|
||||
// 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
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<details class="legacy-login" bind:open={legacyOpen}>
|
||||
<summary>Use an older key with account ID</summary>
|
||||
<form onsubmit={(event) => { event.preventDefault(); void submit(true); }}>
|
||||
<p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> and add a discoverable passkey for ID-free login. Your existing keys are preserved.</p>
|
||||
<p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> to inspect keys and verify migration. Adding with the same older-key authenticator may be excluded; use a different saved-key provider or authenticator. Keep older keys until normal ID-free login to the intended account is tested.</p>
|
||||
<label>Account ID (handle)
|
||||
<input bind:value={handle} required maxlength="128" autocomplete="username" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="legacy-id-help" />
|
||||
</label>
|
||||
@@ -131,6 +131,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#if auth.status}<p role="status">{auth.status}</p>{/if}
|
||||
{#if auth.busy}<button type="button" onclick={() => auth.cancel()}>Stop waiting</button>{/if}
|
||||
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
|
||||
<nav aria-label="Authentication">
|
||||
{#if mode === 'login' && auth.session.kind === 'ready' && !auth.actor}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
{#if !protectedPage}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{#if session.kind === 'loading'}
|
||||
{#if session.kind === 'loading' && !admitted}
|
||||
<div class="auth-page"><p role="status">Loading session…</p></div>
|
||||
{:else if session.kind === 'error'}
|
||||
<div class="auth-page">
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted}
|
||||
<!-- 再確認中は表示/操作を停止し、進行中のlogout等をunmountで取消さない。 -->
|
||||
<div hidden={session.kind !== 'ready'} inert={session.kind !== 'ready'}>{@render children()}</div>
|
||||
<!-- 再確認中は既存内容と操作進捗を可視に保ち、操作だけをinertで停止する。 -->
|
||||
<div hidden={session.kind === 'error'} inert={session.kind !== 'ready'}>{@render children()}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
||||
import { browserSession } from "./session-state.ts";
|
||||
import { activateWithPasskey } from "./activation.ts";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
function confirmedSession() {
|
||||
return {
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
};
|
||||
}
|
||||
function equal(a: unknown, b: unknown) {
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ");
|
||||
}
|
||||
@@ -52,9 +60,14 @@ Deno.test("activation uses only dedicated endpoints and confirms normalized targ
|
||||
await boundary(async (input, init) => {
|
||||
requests.push({ path: String(input), init: init! });
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
String(input) === "/api/auth/whoami"
|
||||
? confirmedSession()
|
||||
: String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: authUser },
|
||||
: {
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
},
|
||||
);
|
||||
}, async (options) => {
|
||||
equal(options.signal === signal, true);
|
||||
@@ -67,16 +80,21 @@ Deno.test("activation uses only dedicated endpoints and confirms normalized targ
|
||||
equal(requests.map((r) => r.path), [
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
for (const { init } of requests) {
|
||||
equal(init.method, "POST");
|
||||
for (const { path, init } of requests) {
|
||||
equal(init.method ?? "GET", path === "/api/auth/whoami" ? "GET" : "POST");
|
||||
equal(init.signal === signal, true);
|
||||
equal(init.credentials, "same-origin");
|
||||
equal(init.redirect, "error");
|
||||
equal(init.mode, "same-origin");
|
||||
equal(init.cache, "no-store");
|
||||
equal(init.referrerPolicy, "no-referrer");
|
||||
if (path !== "/api/auth/whoami") {
|
||||
equal(init.referrerPolicy, "no-referrer");
|
||||
}
|
||||
equal(new Headers(init.headers).has("authorization"), false);
|
||||
}
|
||||
equal(requests[2].init.body, undefined);
|
||||
equal(JSON.parse(String(requests[0].init.body)), {
|
||||
handle: "alice",
|
||||
code: "synthetic-input-only",
|
||||
@@ -125,9 +143,10 @@ Deno.test("activation sanitizes HTTP transport malformed and mismatched completi
|
||||
return Response.json({ unexpected: "synthetic-input-only" });
|
||||
}
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: { ...authUser, handle: "other" } },
|
||||
String(input).endsWith("/options") ? registrationOptions : {
|
||||
user: { ...authUser, handle: "other" },
|
||||
authentication_reference: "fixture-authentication",
|
||||
},
|
||||
);
|
||||
},
|
||||
async () => credentialFixture(),
|
||||
@@ -173,3 +192,201 @@ Deno.test("activation invalid IDs and oversized code are rejected before network
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
for (
|
||||
const mode of [
|
||||
"anonymous",
|
||||
"bob",
|
||||
"account-mismatch",
|
||||
"reference-mismatch",
|
||||
"api-token",
|
||||
"unavailable",
|
||||
] as const
|
||||
) {
|
||||
Deno.test(
|
||||
"activation rejects " + mode +
|
||||
" whoami after successful completion without retry",
|
||||
async () => {
|
||||
const paths: string[] = [];
|
||||
await boundary(
|
||||
async (input) => {
|
||||
const path = String(input);
|
||||
paths.push(path);
|
||||
if (path === "/api/auth/activation/options") {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path !== "/api/auth/whoami") {
|
||||
throw new Error("Unexpected endpoint");
|
||||
}
|
||||
if (mode === "unavailable") {
|
||||
return new Response("private whoami diagnostic", { status: 503 });
|
||||
}
|
||||
if (mode === "anonymous") {
|
||||
return Response.json({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
}
|
||||
const confirmation = confirmedSession();
|
||||
if (mode === "bob") {
|
||||
confirmation.actor = {
|
||||
...confirmation.actor,
|
||||
user_id: "user-bob",
|
||||
account_id: "account-bob",
|
||||
handle: "bob",
|
||||
display_name: "Bob",
|
||||
};
|
||||
}
|
||||
if (mode === "account-mismatch") {
|
||||
confirmation.actor.account_id = "account-bob";
|
||||
}
|
||||
if (mode === "reference-mismatch") {
|
||||
confirmation.authentication_reference = "another-authentication";
|
||||
}
|
||||
if (mode === "api-token") {
|
||||
confirmation.actor.auth_method = "api_token";
|
||||
}
|
||||
return Response.json(confirmation);
|
||||
},
|
||||
async () => credentialFixture(),
|
||||
async () => {
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
"alice",
|
||||
"synthetic-input-only",
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
equal(paths, [
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (const stage of ["prompt", "completion", "confirmation"] as const) {
|
||||
Deno.test(
|
||||
"activation session generation change during " + stage +
|
||||
" fences stale work",
|
||||
async () => {
|
||||
const paths: string[] = [];
|
||||
await boundary(async (input) => {
|
||||
const path = String(input);
|
||||
paths.push(path);
|
||||
if (path === "/api/auth/activation/options") {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
if (stage === "completion") browserSession.invalidate();
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
if (stage === "confirmation") browserSession.invalidate();
|
||||
return Response.json(confirmedSession());
|
||||
}
|
||||
throw new Error("Unexpected endpoint");
|
||||
}, async () => {
|
||||
if (stage === "prompt") browserSession.invalidate();
|
||||
return credentialFixture();
|
||||
}, async () => {
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
"alice",
|
||||
"synthetic-input-only",
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
equal(paths, [
|
||||
"/api/auth/activation/options",
|
||||
...(stage === "prompt" ? [] : ["/api/auth/activation/complete"]),
|
||||
...(stage === "confirmation" ? ["/api/auth/whoami"] : []),
|
||||
]);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (
|
||||
const phase of ["options", "prompt", "complete", "confirmation"] as const
|
||||
) {
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
Deno.test(`activation fences shared ${reason} stamp during ${phase} before event delivery`, async () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"localStorage",
|
||||
);
|
||||
let stamp = "initial-operation-stamp";
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => stamp },
|
||||
});
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const paths: string[] = [];
|
||||
try {
|
||||
await boundary(async (input) => {
|
||||
const path = String(input);
|
||||
paths.push(path);
|
||||
if (
|
||||
(phase === "options" && path.endsWith("/options")) ||
|
||||
(phase === "complete" && path.endsWith("/complete")) ||
|
||||
(phase === "confirmation" && path === "/api/auth/whoami")
|
||||
) {
|
||||
stamp = JSON.stringify({ id: "other-tab", reason });
|
||||
}
|
||||
return Response.json(
|
||||
path.endsWith("/options")
|
||||
? registrationOptions
|
||||
: path.endsWith("/complete")
|
||||
? {
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
}
|
||||
: confirmedSession(),
|
||||
);
|
||||
}, async () => {
|
||||
if (phase === "prompt") {
|
||||
stamp = JSON.stringify({ id: "other-tab", reason });
|
||||
}
|
||||
return credentialFixture();
|
||||
}, async () => {
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
"alice",
|
||||
"synthetic-input-only",
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
equal(browserSession.authenticationGeneration, generation);
|
||||
equal(stamp === "initial-operation-stamp", false);
|
||||
equal(
|
||||
paths.filter((path) => path.endsWith("/complete")).length,
|
||||
phase === "complete" || phase === "confirmation" ? 1 : 0,
|
||||
);
|
||||
equal(paths.includes("/api/auth/whoami"), phase === "confirmation");
|
||||
});
|
||||
} finally {
|
||||
if (previous) {
|
||||
Object.defineProperty(
|
||||
globalThis,
|
||||
"localStorage",
|
||||
previous,
|
||||
);
|
||||
} else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import type {
|
||||
ActivationOptionsRequest,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { loadWhoami, readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { browserSession } from "./session-state.ts";
|
||||
import { normalizeActivationHandle } from "./hosting-accounts.ts";
|
||||
import {
|
||||
type AuthUser,
|
||||
@@ -19,6 +20,16 @@ export async function activateWithPasskey(
|
||||
code: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
// 別tabのstorage event配送より先にcredential/応答が返っても送信・適用しない。
|
||||
const assertCurrent = () => {
|
||||
signal.throwIfAborted();
|
||||
if (
|
||||
generation !== browserSession.authenticationGeneration ||
|
||||
stamp !== browserSession.authenticationStamp
|
||||
) throw new Error("Activation superseded");
|
||||
};
|
||||
try {
|
||||
handle = normalizeActivationHandle(handle);
|
||||
if (window.isSecureContext !== true || !code || code.length > 4096) {
|
||||
@@ -28,7 +39,7 @@ export async function activateWithPasskey(
|
||||
path: "/api/auth/activation/options" | "/api/auth/activation/complete",
|
||||
body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||
): Promise<unknown> => {
|
||||
signal.throwIfAborted();
|
||||
assertCurrent();
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
@@ -44,7 +55,9 @@ export async function activateWithPasskey(
|
||||
await response.body?.cancel();
|
||||
throw new Error("Activation rejected");
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
const result = await readBoundedAuthResponseJson(response);
|
||||
assertCurrent();
|
||||
return result;
|
||||
};
|
||||
const options = await request("/api/auth/activation/options", {
|
||||
handle,
|
||||
@@ -54,22 +67,33 @@ export async function activateWithPasskey(
|
||||
code = "";
|
||||
const publicKey = prepareRegistrationOptions(options);
|
||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||
signal.throwIfAborted();
|
||||
assertCurrent();
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey,
|
||||
signal,
|
||||
});
|
||||
if (!credential) throw new Error("Registration cancelled");
|
||||
signal.throwIfAborted();
|
||||
assertCurrent();
|
||||
const result = parseAuthUserResponse(
|
||||
await request("/api/auth/activation/complete", {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
}),
|
||||
).user;
|
||||
signal.throwIfAborted();
|
||||
if (result.handle !== handle) throw new Error("Activation target mismatch");
|
||||
return result;
|
||||
);
|
||||
assertCurrent();
|
||||
if (result.user.handle !== handle) {
|
||||
throw new Error("Activation target mismatch");
|
||||
}
|
||||
const current = await loadWhoami(signal);
|
||||
assertCurrent();
|
||||
if (
|
||||
current.session.kind !== "authenticated" ||
|
||||
current.actor?.auth_method !== "browser_session" ||
|
||||
current.actor.user_id !== result.user.user_id ||
|
||||
current.actor.account_id !== result.user.account_id ||
|
||||
current.authentication_reference !== result.authentication_reference
|
||||
) throw new Error("Activation session unconfirmed");
|
||||
return result.user;
|
||||
} catch {
|
||||
// HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。
|
||||
throw new Error("Activation could not be confirmed.");
|
||||
|
||||
@@ -67,6 +67,7 @@ export function credentialFixture(authentication = false): Credential {
|
||||
export function whoamiPayload(actor: unknown = null) {
|
||||
return {
|
||||
actor,
|
||||
authentication_reference: actor === null ? null : "fixture-authentication",
|
||||
session: actor === null
|
||||
? { kind: "anonymous" }
|
||||
: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DeviceLoginApproveRequest,
|
||||
PasskeyAdditionOptionsRequest,
|
||||
PasskeyAdditionResultRequest,
|
||||
PasskeyLoginCompleteRequest,
|
||||
PasskeyLoginOptionsRequest,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
@@ -8,24 +9,38 @@ import type {
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import {
|
||||
authenticationCredentialToJson,
|
||||
type AuthUser,
|
||||
AuthPayloadError,
|
||||
type DeviceApprovalResponse,
|
||||
parseAdditionResult,
|
||||
parseAuthUserResponse,
|
||||
parseDeviceApprovalResponse,
|
||||
parseLogoutResponse,
|
||||
parsePasskeyList,
|
||||
parseWhoamiResponse,
|
||||
type PasskeyUserResponse,
|
||||
prepareLoginOptions,
|
||||
prepareRegistrationOptions,
|
||||
registrationCredentialToJson,
|
||||
type WhoamiResponse,
|
||||
} from "#lib/workspace/auth/model.ts";
|
||||
|
||||
import {
|
||||
authCheckpoint,
|
||||
AuthOperationError,
|
||||
type AuthStage,
|
||||
classifyAuthFailure,
|
||||
expectedAuthSession,
|
||||
recordAdditionAttempt,
|
||||
} from "./operation.ts";
|
||||
|
||||
export class AuthRequestError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
constructor(readonly status: number, readonly code: string | null = null) {
|
||||
super(`Auth request failed (${status}).`);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthResponseError extends Error {}
|
||||
|
||||
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
|
||||
|
||||
export async function readBoundedAuthResponseJson(
|
||||
@@ -39,13 +54,15 @@ export async function readBoundedAuthResponseJson(
|
||||
parsed > MAX_AUTH_RESPONSE_BYTES
|
||||
) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(
|
||||
throw new AuthResponseError(
|
||||
"Invalid auth response: response body exceeds the size limit.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.body === null) {
|
||||
throw new Error("Invalid auth response: response body is missing.");
|
||||
throw new AuthResponseError(
|
||||
"Invalid auth response: response body is missing.",
|
||||
);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
@@ -58,7 +75,7 @@ export async function readBoundedAuthResponseJson(
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > MAX_AUTH_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new Error(
|
||||
throw new AuthResponseError(
|
||||
"Invalid auth response: response body exceeds the size limit.",
|
||||
);
|
||||
}
|
||||
@@ -79,7 +96,9 @@ export async function readBoundedAuthResponseJson(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||||
) as unknown;
|
||||
} catch {
|
||||
throw new Error("Invalid auth response: response body is not valid JSON.");
|
||||
throw new AuthResponseError(
|
||||
"Invalid auth response: response body is not valid JSON.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +115,27 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new AuthRequestError(response.status);
|
||||
let code: string | null = null;
|
||||
try {
|
||||
const value = await readBoundedAuthResponseJson(response);
|
||||
const message = typeof value === "object" && value !== null
|
||||
? (value as { message?: unknown }).message
|
||||
: null;
|
||||
if (typeof message === "string") {
|
||||
code = [
|
||||
"auth_required",
|
||||
"passkey_authentication_changed",
|
||||
"passkey_registration_binding_mismatch",
|
||||
"webauthn_registration_verification_failed",
|
||||
"webauthn_login_verification_failed",
|
||||
"invalid_passkey_challenge",
|
||||
"expired_passkey_challenge",
|
||||
"self_registration_disabled",
|
||||
"passkey_activation_required",
|
||||
].find((code) => message.startsWith(code + ":")) ?? null;
|
||||
}
|
||||
} catch { /* 本文・解析例外は保存しない。statusだけでも失敗として扱う。 */ }
|
||||
throw new AuthRequestError(response.status, code);
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
}
|
||||
@@ -122,7 +160,7 @@ export async function registerPasskey(
|
||||
handle: string,
|
||||
displayName?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
): Promise<PasskeyUserResponse> {
|
||||
const optionsRequest: PasskeyRegistrationOptionsRequest = {
|
||||
handle,
|
||||
display_name: displayName ?? null,
|
||||
@@ -131,8 +169,21 @@ export async function registerPasskey(
|
||||
return await createPasskey("registration", optionsRequest, signal);
|
||||
}
|
||||
|
||||
export async function addPasskey(signal?: AbortSignal): Promise<AuthUser> {
|
||||
export async function addPasskey(
|
||||
signal?: AbortSignal,
|
||||
): Promise<PasskeyUserResponse> {
|
||||
const expected = expectedAuthSession(signal) ?? await loadWhoami(signal);
|
||||
if (!expected.actor || !expected.authentication_reference) {
|
||||
throw new AuthOperationError(
|
||||
"session-check",
|
||||
"session-changed",
|
||||
"not-sent",
|
||||
);
|
||||
}
|
||||
const optionsRequest: PasskeyAdditionOptionsRequest = {
|
||||
expected_user_id: expected.actor.user_id,
|
||||
expected_account_id: expected.actor.account_id,
|
||||
expected_authentication_reference: expected.authentication_reference,
|
||||
browser_origin: window.location.origin,
|
||||
};
|
||||
return await createPasskey("addition", optionsRequest, signal);
|
||||
@@ -144,51 +195,88 @@ async function createPasskey(
|
||||
| PasskeyRegistrationOptionsRequest
|
||||
| PasskeyAdditionOptionsRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(optionsRequest),
|
||||
signal,
|
||||
});
|
||||
const optionsRecord = typeof options === "object" && options !== null
|
||||
? options as Record<string, unknown>
|
||||
: null;
|
||||
const challengeId = optionsRecord?.challenge_id;
|
||||
if (typeof challengeId !== "string" || challengeId.length === 0) {
|
||||
throw new Error(
|
||||
"Invalid auth payload: registration_options.challenge_id is required.",
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (typeof navigator.credentials?.create !== "function") {
|
||||
throw new DOMException("Passkeys unavailable", "NotSupportedError");
|
||||
}
|
||||
const credential = await navigator.credentials.create({
|
||||
signal,
|
||||
publicKey: prepareRegistrationOptions(options),
|
||||
});
|
||||
if (!credential) {
|
||||
throw new DOMException("Passkey cancelled", "NotAllowedError");
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const completeRequest: PasskeyRegistrationCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
};
|
||||
const result = parseAuthUserResponse(
|
||||
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
|
||||
): Promise<PasskeyUserResponse> {
|
||||
let stage: AuthStage = "options";
|
||||
let sent = false;
|
||||
try {
|
||||
await authCheckpoint(signal, stage);
|
||||
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
body: JSON.stringify(optionsRequest),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
return result.user;
|
||||
});
|
||||
const publicKey = prepareRegistrationOptions(options);
|
||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||
stage = "authenticator";
|
||||
await authCheckpoint(signal, stage);
|
||||
if (typeof navigator.credentials?.create !== "function") {
|
||||
throw new DOMException("", "NotSupportedError");
|
||||
}
|
||||
const credential = await navigator.credentials.create({
|
||||
signal,
|
||||
publicKey,
|
||||
});
|
||||
if (!credential) throw new DOMException("", "NotAllowedError");
|
||||
signal?.throwIfAborted();
|
||||
stage = "conversion";
|
||||
await authCheckpoint(signal, stage);
|
||||
const completeRequest: PasskeyRegistrationCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
};
|
||||
stage = "before-complete";
|
||||
await authCheckpoint(signal, stage);
|
||||
stage = "complete";
|
||||
await authCheckpoint(signal, stage);
|
||||
if (purpose === "addition") {
|
||||
const expected = optionsRequest as PasskeyAdditionOptionsRequest;
|
||||
recordAdditionAttempt(signal, {
|
||||
challenge_id: challengeId,
|
||||
expected_user_id: expected.expected_user_id,
|
||||
expected_account_id: expected.expected_account_id,
|
||||
expected_authentication_reference:
|
||||
expected.expected_authentication_reference,
|
||||
});
|
||||
}
|
||||
sent = true;
|
||||
const result = parseAuthUserResponse(
|
||||
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequestError) {
|
||||
throw new AuthOperationError(
|
||||
stage,
|
||||
error.code === "auth_required" ||
|
||||
error.code === "passkey_authentication_changed" ||
|
||||
error.code === "passkey_registration_binding_mismatch"
|
||||
? "session-changed"
|
||||
: "rejected",
|
||||
sent ? error.status >= 500 ? "unknown" : "rejected" : "not-sent",
|
||||
error.status,
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof AuthResponseError || error instanceof AuthPayloadError
|
||||
) {
|
||||
throw new AuthOperationError(
|
||||
stage,
|
||||
stage === "conversion" ? "conversion" : "invalid-response",
|
||||
sent ? "unknown" : "not-sent",
|
||||
);
|
||||
}
|
||||
throw classifyAuthFailure(error, stage, sent);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
): Promise<PasskeyUserResponse> {
|
||||
return await authenticateWithPasskey({
|
||||
browser_origin: window.location.origin,
|
||||
}, signal);
|
||||
@@ -197,7 +285,7 @@ export async function loginWithPasskey(
|
||||
export async function loginWithLegacyPasskey(
|
||||
handle: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
): Promise<PasskeyUserResponse> {
|
||||
const loginHandle = handle.trim();
|
||||
if (!loginHandle) {
|
||||
throw new Error("User handle is required for legacy passkey login.");
|
||||
@@ -211,52 +299,77 @@ export async function loginWithLegacyPasskey(
|
||||
async function authenticateWithPasskey(
|
||||
optionsRequest: PasskeyLoginOptionsRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
const options = await requestJson("/api/auth/passkeys/login/options", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(optionsRequest),
|
||||
signal,
|
||||
});
|
||||
const optionsRecord = typeof options === "object" && options !== null
|
||||
? options as Record<string, unknown>
|
||||
: null;
|
||||
const challengeId = optionsRecord?.challenge_id;
|
||||
if (typeof challengeId !== "string" || challengeId.length === 0) {
|
||||
throw new Error(
|
||||
"Invalid auth payload: login_options.challenge_id is required.",
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (typeof navigator.credentials?.get !== "function") {
|
||||
throw new DOMException("Passkeys unavailable", "NotSupportedError");
|
||||
}
|
||||
const credential = await navigator.credentials.get({
|
||||
signal,
|
||||
publicKey: prepareLoginOptions(options),
|
||||
});
|
||||
if (!credential) {
|
||||
throw new DOMException("Passkey cancelled", "NotAllowedError");
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const completeRequest: PasskeyLoginCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: authenticationCredentialToJson(credential),
|
||||
};
|
||||
const result = parseAuthUserResponse(
|
||||
await requestJson("/api/auth/passkeys/login/complete", {
|
||||
): Promise<PasskeyUserResponse> {
|
||||
let stage: AuthStage = "options";
|
||||
let sent = false;
|
||||
try {
|
||||
await authCheckpoint(signal, stage);
|
||||
const options = await requestJson("/api/auth/passkeys/login/options", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
body: JSON.stringify(optionsRequest),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
return result.user;
|
||||
});
|
||||
const publicKey = prepareLoginOptions(options);
|
||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||
stage = "authenticator";
|
||||
await authCheckpoint(signal, stage);
|
||||
if (typeof navigator.credentials?.get !== "function") {
|
||||
throw new DOMException("", "NotSupportedError");
|
||||
}
|
||||
const credential = await navigator.credentials.get({ signal, publicKey });
|
||||
if (!credential) throw new DOMException("", "NotAllowedError");
|
||||
signal?.throwIfAborted();
|
||||
stage = "conversion";
|
||||
await authCheckpoint(signal, stage);
|
||||
const completeRequest: PasskeyLoginCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: authenticationCredentialToJson(credential),
|
||||
};
|
||||
stage = "before-complete";
|
||||
await authCheckpoint(signal, stage);
|
||||
stage = "complete";
|
||||
await authCheckpoint(signal, stage);
|
||||
sent = true;
|
||||
const result = parseAuthUserResponse(
|
||||
await requestJson("/api/auth/passkeys/login/complete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequestError) {
|
||||
throw new AuthOperationError(
|
||||
stage,
|
||||
error.code === "auth_required" ||
|
||||
error.code === "passkey_authentication_changed" ||
|
||||
error.code === "passkey_registration_binding_mismatch"
|
||||
? "session-changed"
|
||||
: "rejected",
|
||||
sent ? error.status >= 500 ? "unknown" : "rejected" : "not-sent",
|
||||
error.status,
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof AuthResponseError || error instanceof AuthPayloadError
|
||||
) {
|
||||
throw new AuthOperationError(
|
||||
stage,
|
||||
stage === "conversion" ? "conversion" : "invalid-response",
|
||||
sent ? "unknown" : "not-sent",
|
||||
);
|
||||
}
|
||||
throw classifyAuthFailure(error, stage, sent);
|
||||
}
|
||||
}
|
||||
|
||||
export async function approveDeviceLogin(
|
||||
userCode: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DeviceApprovalResponse> {
|
||||
await authCheckpoint(signal, "before-complete");
|
||||
const request: DeviceLoginApproveRequest = { user_code: userCode };
|
||||
return parseDeviceApprovalResponse(
|
||||
await requestJson("/api/auth/device-login/approve", {
|
||||
@@ -266,3 +379,19 @@ export async function approveDeviceLogin(
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadPasskeys(signal?: AbortSignal) {
|
||||
return parsePasskeyList(await requestJson("/api/auth/passkeys", { signal }));
|
||||
}
|
||||
export async function readAdditionResult(
|
||||
request: PasskeyAdditionResultRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return parseAdditionResult(
|
||||
await requestJson("/api/auth/passkeys/addition/result", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { AuthRequestError } from "./api.ts";
|
||||
import type { PasskeyAdditionResultRequest } from "#lib/generated/auth-api.ts";
|
||||
import { AuthRequestError, loadWhoami, readAdditionResult } from "./api.ts";
|
||||
import {
|
||||
browserSession,
|
||||
observeBrowserSession,
|
||||
sessionMutation,
|
||||
} from "./session-state.ts";
|
||||
import { notifySessionChanged } from "./hosting.ts";
|
||||
import type { RequestActor, WhoamiResponse } from "./model.ts";
|
||||
import type { AuthUser, RequestActor, WhoamiResponse } from "./model.ts";
|
||||
import {
|
||||
AuthOperationError,
|
||||
type AuthStage,
|
||||
observeAuthOperation,
|
||||
operationErrorMessage,
|
||||
} from "./operation.ts";
|
||||
|
||||
export type BrowserSession =
|
||||
| { kind: "loading" }
|
||||
@@ -13,41 +20,60 @@ export type BrowserSession =
|
||||
| ({ kind: "ready" } & WhoamiResponse);
|
||||
|
||||
export function authErrorMessage(error: unknown): string {
|
||||
if (error instanceof AuthOperationError) return operationErrorMessage(error);
|
||||
if (error instanceof AuthRequestError) {
|
||||
if (error.status === 401) {
|
||||
return "Authentication request was rejected. Check your session or restart the Passkey operation.";
|
||||
}
|
||||
if (error.status === 400 || error.status === 410) {
|
||||
return "The request or authentication challenge is invalid or expired. Restart the operation.";
|
||||
}
|
||||
return error.message;
|
||||
return `Authentication request was rejected (${error.status}). Check your session or restart the operation.`;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "NotSupportedError") {
|
||||
return "This browser or key cannot complete this passkey operation. Use a supported device or key with saved passkeys and PIN or biometric verification. Completion is not confirmed.";
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "NotAllowedError") {
|
||||
return "Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again.";
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return "Operation stopped. Completion is not confirmed; check your session before retrying.";
|
||||
}
|
||||
// Browser/transport例外の本文を表示せず、credentialや外部応答を診断へ複写しない。
|
||||
return "Authentication could not be confirmed. Check your session before retrying the operation.";
|
||||
// 例外本文/causeや外部応答は表示にも診断にも複写しない。
|
||||
return "Authentication could not be confirmed. Check your session and the operation result before retrying.";
|
||||
}
|
||||
function sameUser(a: AuthUser | null, b: AuthUser | null): boolean {
|
||||
return a === null
|
||||
? b === null
|
||||
: b !== null && a.user_id === b.user_id && a.account_id === b.account_id;
|
||||
}
|
||||
function sameSession(a: WhoamiResponse, b: WhoamiResponse): boolean {
|
||||
// anonymous/expiredはいずれも認証主体もbindingもない。別tabのlogoutでexpiredの
|
||||
// 表示だけが古くなっても、新たな明示loginを拒否しない。認証済みへの変化は拒否する。
|
||||
if (
|
||||
!a.actor && !b.actor && a.session.kind !== "authenticated" &&
|
||||
b.session.kind !== "authenticated"
|
||||
) return true;
|
||||
return sameUser(a.actor, b.actor) && a.session.kind === b.session.kind &&
|
||||
(a.session.kind !== "authenticated" ||
|
||||
a.authentication_reference === b.authentication_reference &&
|
||||
b.session.kind === "authenticated" &&
|
||||
a.session.expires_at === b.session.expires_at &&
|
||||
a.actor?.auth_method === b.actor?.auth_method);
|
||||
}
|
||||
function returnedUser(value: unknown): AuthUser | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const user = (value as { user?: Partial<AuthUser> }).user;
|
||||
return user && typeof user.user_id === "string" &&
|
||||
typeof user.account_id === "string" && typeof user.handle === "string" &&
|
||||
typeof user.display_name === "string"
|
||||
? user as AuthUser
|
||||
: null;
|
||||
}
|
||||
|
||||
export class BrowserAuth {
|
||||
session = $state<BrowserSession>({ kind: "loading" });
|
||||
busy = $state(false);
|
||||
error = $state("");
|
||||
status = $state("");
|
||||
additionAttempt = $state<PasskeyAdditionResultRequest | null>(null);
|
||||
additionSaved = $state(false);
|
||||
private unsubscribe: () => void;
|
||||
private disposed = false;
|
||||
private controller: AbortController | null = null;
|
||||
private operationGeneration: number | null = null;
|
||||
|
||||
constructor() {
|
||||
observeBrowserSession();
|
||||
this.unsubscribe = browserSession.subscribe((snapshot) => {
|
||||
// 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。
|
||||
if (
|
||||
this.operationGeneration !== null &&
|
||||
this.operationGeneration !== browserSession.authenticationGeneration
|
||||
) this.controller?.abort();
|
||||
if (this.busy && snapshot.kind === "loading") return;
|
||||
if (!this.disposed) {
|
||||
this.session = snapshot.kind === "ready"
|
||||
@@ -56,75 +82,239 @@ export class BrowserAuth {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get actor(): RequestActor | null {
|
||||
return this.session.kind === "ready" ? this.session.actor : null;
|
||||
}
|
||||
|
||||
async ensure(): Promise<void> {
|
||||
if (this.busy || this.disposed) return;
|
||||
await browserSession.ensure();
|
||||
if (!this.busy && !this.disposed) await browserSession.ensure();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (this.busy || this.disposed) return;
|
||||
await browserSession.refresh();
|
||||
if (!this.busy && !this.disposed) await browserSession.refresh();
|
||||
}
|
||||
cancel(): void {
|
||||
this.controller?.abort();
|
||||
}
|
||||
|
||||
async run(
|
||||
pending: string,
|
||||
success: string,
|
||||
operation: (signal: AbortSignal) => Promise<unknown>,
|
||||
intent: "authentication" | "logout" = "authentication",
|
||||
intent: "authentication" | "logout" | "device-approval" = "authentication",
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
this.busy || this.disposed ||
|
||||
(intent !== "logout" && this.session.kind !== "ready")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
) return false;
|
||||
const mutationReason = intent === "logout" ? "logout" : "authentication";
|
||||
const expected = this.session.kind === "ready" ? this.session : null;
|
||||
this.busy = true;
|
||||
sessionMutation(intent);
|
||||
const authenticationGeneration = browserSession.authenticationGeneration;
|
||||
sessionMutation(mutationReason, true);
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
let acceptedGeneration = generation;
|
||||
let acceptedStamp = stamp;
|
||||
let submittedAddition = false;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.operationGeneration = generation;
|
||||
this.error = "";
|
||||
this.status = pending;
|
||||
const verifySession = async () => {
|
||||
if (stamp !== browserSession.authenticationStamp) controller.abort();
|
||||
controller.signal.throwIfAborted();
|
||||
let current: WhoamiResponse;
|
||||
try {
|
||||
current = await loadWhoami(controller.signal);
|
||||
} catch {
|
||||
throw new AuthOperationError(
|
||||
"session-check",
|
||||
"session-changed",
|
||||
"not-sent",
|
||||
);
|
||||
}
|
||||
if (stamp !== browserSession.authenticationStamp) controller.abort();
|
||||
controller.signal.throwIfAborted();
|
||||
if (!expected || !sameSession(expected, current)) {
|
||||
throw new AuthOperationError(
|
||||
"session-check",
|
||||
"session-changed",
|
||||
"not-sent",
|
||||
);
|
||||
}
|
||||
};
|
||||
observeAuthOperation(controller.signal, {
|
||||
verifySession,
|
||||
expected,
|
||||
additionAttempt: (attempt) => {
|
||||
submittedAddition = true;
|
||||
this.additionAttempt = attempt;
|
||||
this.additionSaved = false;
|
||||
},
|
||||
phase: (stage: AuthStage) => {
|
||||
if (!this.disposed) {
|
||||
this.status = stage === "authenticator"
|
||||
? pending
|
||||
: stage === "complete"
|
||||
? "Confirming the operation with the server…"
|
||||
: stage === "before-complete"
|
||||
? "Checking the account before completion…"
|
||||
: stage === "options"
|
||||
? "Checking the account and requesting passkey options…"
|
||||
: "Preparing the authenticator response…";
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
await operation(controller.signal);
|
||||
const superseded =
|
||||
authenticationGeneration !== browserSession.authenticationGeneration;
|
||||
sessionMutation(intent);
|
||||
if (intent === "device-approval") await verifySession();
|
||||
const result = await operation(controller.signal);
|
||||
const user = returnedUser(result);
|
||||
const issuedReference = typeof result === "object" && result !== null
|
||||
? (result as { authentication_reference?: unknown })
|
||||
.authentication_reference
|
||||
: null;
|
||||
controller.signal.throwIfAborted();
|
||||
if (
|
||||
this.disposed ||
|
||||
generation !== browserSession.authenticationGeneration ||
|
||||
stamp !== browserSession.authenticationStamp
|
||||
) return false;
|
||||
// 自身の再確認によるinvalidateと、他の操作が行ったinvalidateを混同しない。
|
||||
this.operationGeneration = null;
|
||||
sessionMutation(mutationReason, true);
|
||||
const confirmationGeneration = browserSession.authenticationGeneration;
|
||||
acceptedGeneration = confirmationGeneration;
|
||||
const confirmationStamp = browserSession.authenticationStamp;
|
||||
acceptedStamp = confirmationStamp;
|
||||
this.status = "Confirming the expected browser session…";
|
||||
await browserSession.refresh();
|
||||
if (
|
||||
this.disposed || superseded ||
|
||||
confirmationGeneration !== browserSession.authenticationGeneration
|
||||
this.disposed || controller.signal.aborted ||
|
||||
confirmationGeneration !== browserSession.authenticationGeneration ||
|
||||
confirmationStamp !== browserSession.authenticationStamp
|
||||
) {
|
||||
if (!this.disposed) this.status = "";
|
||||
return false;
|
||||
}
|
||||
const snapshot = browserSession.snapshot;
|
||||
const confirmed = snapshot.kind === "ready" &&
|
||||
(intent === "logout"
|
||||
? snapshot.value.actor === null &&
|
||||
snapshot.value.session.kind !== "authenticated"
|
||||
: intent === "device-approval"
|
||||
? snapshot.value.session.kind === "authenticated" && user !== null &&
|
||||
sameUser(user, expected?.actor ?? null) &&
|
||||
sameUser(user, snapshot.value.actor) &&
|
||||
snapshot.value.authentication_reference ===
|
||||
expected?.authentication_reference &&
|
||||
typeof result === "object" && result !== null &&
|
||||
(result as { status?: unknown }).status === "approved"
|
||||
: snapshot.value.session.kind === "authenticated" &&
|
||||
snapshot.value.actor?.auth_method === "browser_session" &&
|
||||
user !== null && typeof issuedReference === "string" &&
|
||||
issuedReference === snapshot.value.authentication_reference &&
|
||||
sameUser(user, snapshot.value.actor) &&
|
||||
(!expected?.actor || sameUser(expected.actor, user)));
|
||||
if (!confirmed) {
|
||||
throw new AuthOperationError(
|
||||
"confirmation",
|
||||
"session-changed",
|
||||
"saved-session-unconfirmed",
|
||||
);
|
||||
}
|
||||
notifySessionChanged();
|
||||
const confirmed = this.session.kind === "ready";
|
||||
this.status = confirmed ? success : "";
|
||||
return confirmed;
|
||||
if (submittedAddition) this.additionSaved = true;
|
||||
this.status = success;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.disposed) {
|
||||
this.error = authErrorMessage(error);
|
||||
this.status = "";
|
||||
}
|
||||
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。
|
||||
sessionMutation(intent);
|
||||
await browserSession.refresh();
|
||||
// 古い処理から新しいログアウト/主体確認をinvalidateしない。
|
||||
if (
|
||||
!this.disposed &&
|
||||
acceptedGeneration === browserSession.authenticationGeneration &&
|
||||
acceptedStamp === browserSession.authenticationStamp
|
||||
) {
|
||||
this.operationGeneration = null;
|
||||
sessionMutation(mutationReason, true);
|
||||
await browserSession.refresh();
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
this.operationGeneration = null;
|
||||
this.controller = null;
|
||||
if (!this.disposed) this.busy = false;
|
||||
}
|
||||
}
|
||||
async checkAddition(): Promise<void> {
|
||||
const attempt = this.additionAttempt;
|
||||
if (!attempt || this.busy || this.disposed) return;
|
||||
this.busy = true;
|
||||
this.error = "";
|
||||
this.status = "Checking the saved addition result…";
|
||||
// 保存の履歴は失敗に戻さないが、過去の照合で再追加を許可しない。
|
||||
this.additionSaved = false;
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.operationGeneration = generation;
|
||||
const assertCurrent = () => {
|
||||
controller.signal.throwIfAborted();
|
||||
if (
|
||||
this.disposed ||
|
||||
generation !== browserSession.authenticationGeneration ||
|
||||
stamp !== browserSession.authenticationStamp
|
||||
) {
|
||||
throw new AuthOperationError(
|
||||
"confirmation",
|
||||
"session-changed",
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const before = await loadWhoami(controller.signal);
|
||||
assertCurrent();
|
||||
if (
|
||||
!before.actor || before.actor.user_id !== attempt.expected_user_id ||
|
||||
before.actor.account_id !== attempt.expected_account_id
|
||||
) {
|
||||
throw new AuthOperationError(
|
||||
"confirmation",
|
||||
"session-changed",
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
const result = await readAdditionResult(attempt, controller.signal);
|
||||
assertCurrent();
|
||||
const after = await loadWhoami(controller.signal);
|
||||
assertCurrent();
|
||||
if (!sameSession(before, after)) {
|
||||
throw new AuthOperationError(
|
||||
"confirmation",
|
||||
"session-changed",
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
if (result.status === "saved" && sameUser(result.user, before.actor)) {
|
||||
this.additionSaved = true;
|
||||
this.status =
|
||||
"This addition saved a key to your account. Your current session was checked; the session issued by completion is not confirmed. Test ID-free login with that key before retiring older-key access.";
|
||||
} else {this.status =
|
||||
"The addition result is still unknown. Completion may still be in flight. Do not add another key yet; check again or use the registered-key list and ID-free login to verify access.";}
|
||||
} catch {
|
||||
if (!this.disposed) {
|
||||
this.busy = false;
|
||||
this.controller = null;
|
||||
this.error =
|
||||
"The addition result could not be read with the current account/session. This does not prove failure. Return to the intended account and check its key list or ID-free login; do not recreate the original session or add blindly.";
|
||||
this.status = "";
|
||||
}
|
||||
} finally {
|
||||
this.operationGeneration = null;
|
||||
this.controller = null;
|
||||
if (!this.disposed) this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.unsubscribe();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { HostingRequestError } from "./hosting.ts";
|
||||
import { parseAuthUserResponse } from "./model.ts";
|
||||
import { parseAuthUser } from "./model.ts";
|
||||
|
||||
// Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。
|
||||
export function normalizeActivationHandle(value: string): string {
|
||||
@@ -57,7 +57,7 @@ export async function issueActivationCode(
|
||||
typeof record.expires_at !== "string" ||
|
||||
!Number.isFinite(Date.parse(record.expires_at))
|
||||
) throw new Error();
|
||||
const user = parseAuthUserResponse({ user: record.user }).user;
|
||||
const user = parseAuthUser(record.user);
|
||||
if (user.handle !== handle) throw new Error();
|
||||
signal.throwIfAborted();
|
||||
return { user, code: record.code, expires_at: record.expires_at };
|
||||
|
||||
@@ -7,6 +7,16 @@ import {
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
|
||||
const bearerIdentity = {
|
||||
actor: { ...authUser, auth_method: "api_token" },
|
||||
authentication_reference: "fixture-bearer-authentication",
|
||||
session: { kind: "authenticated", expires_at: null },
|
||||
};
|
||||
const completion = {
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-issued-authentication",
|
||||
};
|
||||
|
||||
function equal(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error("Values differ");
|
||||
@@ -65,9 +75,11 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
|
||||
await boundary(async (input, init) => {
|
||||
requests.push({ path: String(input), init: init! });
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
String(input).endsWith("/whoami")
|
||||
? bearerIdentity
|
||||
: String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: authUser },
|
||||
: completion,
|
||||
);
|
||||
}, async () => {
|
||||
equal(
|
||||
@@ -75,11 +87,13 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
|
||||
authUser,
|
||||
);
|
||||
equal(requests.map((request) => request.path), [
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
for (const { init } of requests) {
|
||||
equal(init.method, "POST");
|
||||
for (const { path, init } of requests) {
|
||||
equal(init.method, path.endsWith("/whoami") ? "GET" : "POST");
|
||||
equal(new Headers(init.headers).has("cookie"), false);
|
||||
equal(init.credentials, "omit");
|
||||
equal(init.mode, "same-origin");
|
||||
equal(init.redirect, "error");
|
||||
@@ -91,10 +105,15 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
|
||||
);
|
||||
equal(String(init.body).includes("host-test-token"), false);
|
||||
}
|
||||
equal(JSON.parse(String(requests[0].init.body)), {
|
||||
equal(requests[0].init.body, undefined);
|
||||
equal(JSON.parse(String(requests[1].init.body)), {
|
||||
expected_user_id: authUser.user_id,
|
||||
expected_account_id: authUser.account_id,
|
||||
expected_authentication_reference:
|
||||
bearerIdentity.authentication_reference,
|
||||
browser_origin: "https://yoi.example",
|
||||
});
|
||||
const complete = JSON.parse(String(requests[1].init.body));
|
||||
const complete = JSON.parse(String(requests[2].init.body));
|
||||
equal(Object.keys(complete).sort(), ["challenge_id", "credential"]);
|
||||
equal(complete.challenge_id, registrationOptions.challenge_id);
|
||||
equal(complete.credential.response.attestationObject, "AQID");
|
||||
@@ -151,29 +170,46 @@ Deno.test("host enrollment validates options before opening credential prompt",
|
||||
}]
|
||||
) {
|
||||
let calls = 0;
|
||||
await boundary(async () => Response.json(payload), async () => {
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 0);
|
||||
}, async () => {
|
||||
calls++;
|
||||
return credentialFixture();
|
||||
});
|
||||
await boundary(
|
||||
async (input) =>
|
||||
Response.json(
|
||||
String(input).endsWith("/whoami") ? bearerIdentity : payload,
|
||||
),
|
||||
async () => {
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 0);
|
||||
},
|
||||
async () => {
|
||||
calls++;
|
||||
return credentialFixture();
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("host enrollment cancellation and teardown prevent completion", async () => {
|
||||
for (const abort of [false, true]) {
|
||||
const controller = new AbortController();
|
||||
let requests = 0;
|
||||
await boundary(async () => {
|
||||
requests++;
|
||||
return Response.json(registrationOptions);
|
||||
const requests: string[] = [];
|
||||
let prompts = 0;
|
||||
await boundary(async (input) => {
|
||||
requests.push(String(input));
|
||||
return Response.json(
|
||||
String(input).endsWith("/whoami")
|
||||
? bearerIdentity
|
||||
: registrationOptions,
|
||||
);
|
||||
}, async () => {
|
||||
await rejects(enrollHostingPasskey("host-test-token", controller.signal));
|
||||
equal(requests, 1);
|
||||
equal(requests, [
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/options",
|
||||
]);
|
||||
equal(prompts, 1);
|
||||
}, async () => {
|
||||
prompts++;
|
||||
if (abort) {
|
||||
controller.abort();
|
||||
return credentialFixture();
|
||||
@@ -188,6 +224,9 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
|
||||
let calls = 0;
|
||||
await boundary(async (input) => {
|
||||
calls++;
|
||||
if (String(input).endsWith("/whoami")) {
|
||||
return Response.json(bearerIdentity);
|
||||
}
|
||||
if (String(input).endsWith("/options")) {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
@@ -201,7 +240,7 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 2);
|
||||
equal(calls, 3);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import {
|
||||
type AuthUser,
|
||||
parseAuthUserResponse,
|
||||
parseWhoamiResponse,
|
||||
prepareRegistrationOptions,
|
||||
registrationCredentialToJson,
|
||||
} from "./model.ts";
|
||||
@@ -26,13 +27,14 @@ export async function enrollHostingPasskey(
|
||||
}
|
||||
const request = async (
|
||||
path:
|
||||
| "/api/auth/whoami"
|
||||
| "/api/auth/passkeys/addition/options"
|
||||
| "/api/auth/passkeys/addition/complete",
|
||||
body: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||
body?: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||
): Promise<unknown> => {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
method: path === "/api/auth/whoami" ? "GET" : "POST",
|
||||
// Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、
|
||||
// completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。
|
||||
credentials: "omit",
|
||||
@@ -53,7 +55,12 @@ export async function enrollHostingPasskey(
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
};
|
||||
const identity = parseWhoamiResponse(await request("/api/auth/whoami"));
|
||||
if (!identity.actor || !identity.authentication_reference || identity.actor.auth_method !== "api_token") throw new Error("Setup identity unconfirmed");
|
||||
const options = await request("/api/auth/passkeys/addition/options", {
|
||||
expected_user_id: identity.actor.user_id,
|
||||
expected_account_id: identity.actor.account_id,
|
||||
expected_authentication_reference: identity.authentication_reference,
|
||||
browser_origin: window.location.origin,
|
||||
});
|
||||
const publicKey = prepareRegistrationOptions(options);
|
||||
|
||||
@@ -4,6 +4,7 @@ declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import { AuthOperationError } from "./operation.ts";
|
||||
import { loginWithLegacyPasskey, loginWithPasskey } from "./api.ts";
|
||||
import {
|
||||
authUser,
|
||||
@@ -64,7 +65,12 @@ async function boundary(
|
||||
Response.json(body.handle ? legacyLoginOptions : loginOptions),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(Response.json({ user: authUser }));
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -97,7 +103,7 @@ for (const legacy of [false, true]) {
|
||||
await (legacy
|
||||
? loginWithLegacyPasskey(" alice ", signal)
|
||||
: loginWithPasskey(signal)),
|
||||
authUser,
|
||||
{ user: authUser, authentication_reference: "fixture-authentication" },
|
||||
);
|
||||
equal(state.requests.map(({ path }) => path), [
|
||||
"/api/auth/passkeys/login/options",
|
||||
@@ -131,8 +137,10 @@ for (const legacy of [false, true]) {
|
||||
: loginWithPasskey(controller.signal));
|
||||
throw new Error("Unexpected login success");
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException)) throw error;
|
||||
equal(error.name, late ? "AbortError" : "NotAllowedError");
|
||||
if (!(error instanceof AuthOperationError)) throw error;
|
||||
equal(error.stage, "authenticator");
|
||||
equal(error.reason, late ? "stopped" : "cancelled-or-unavailable");
|
||||
equal(error.outcome, "not-sent");
|
||||
}
|
||||
equal(state.requests.length, 1);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
authenticationCredentialToJson,
|
||||
base64UrlToBuffer,
|
||||
bufferToBase64Url,
|
||||
parseAuthUserResponse,
|
||||
parseDeviceLoginPollResponse,
|
||||
parseDeviceLoginStartResponse,
|
||||
parseWhoamiResponse,
|
||||
@@ -119,12 +120,21 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
|
||||
|
||||
Deno.test("whoami rejects unknown auth methods and extra fields", () => {
|
||||
assertEquals(
|
||||
parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }),
|
||||
{ actor: null, session: { kind: "anonymous" } },
|
||||
parseWhoamiResponse({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
}),
|
||||
{
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
},
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWhoamiResponse({
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: {
|
||||
user_id: "user-1",
|
||||
@@ -137,7 +147,12 @@ Deno.test("whoami rejects unknown auth methods and extra fields", () => {
|
||||
"unknown auth method",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseWhoamiResponse({ actor: null, token: "secret" }),
|
||||
() =>
|
||||
parseWhoamiResponse({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
token: "secret",
|
||||
}),
|
||||
"unexpected whoami field",
|
||||
);
|
||||
});
|
||||
@@ -197,7 +212,11 @@ Deno.test("auth validation enforces cumulative budgets without echoing unknown k
|
||||
|
||||
const attackerKey = `secret-${"x".repeat(512)}`;
|
||||
try {
|
||||
parseWhoamiResponse({ actor: null, [attackerKey]: true });
|
||||
parseWhoamiResponse({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
[attackerKey]: true,
|
||||
});
|
||||
throw new Error("expected an error");
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message.includes(attackerKey)) {
|
||||
@@ -248,23 +267,153 @@ Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", ()
|
||||
};
|
||||
for (
|
||||
const payload of [
|
||||
{ actor, session: { kind: "anonymous" } },
|
||||
{ actor, session: { kind: "expired" } },
|
||||
{ actor, authentication_reference: null, session: { kind: "anonymous" } },
|
||||
{ actor, authentication_reference: null, session: { kind: "expired" } },
|
||||
{
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
},
|
||||
{ actor, session: { kind: "authenticated", expires_at: null } },
|
||||
{ actor, session: { kind: "authenticated", expires_at: "tomorrow" } },
|
||||
{ actor: null, session: { kind: "future" } },
|
||||
{
|
||||
actor,
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: null },
|
||||
},
|
||||
{
|
||||
actor,
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "tomorrow" },
|
||||
},
|
||||
{
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "future" },
|
||||
},
|
||||
{
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" },
|
||||
},
|
||||
]
|
||||
) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami");
|
||||
assertEquals(
|
||||
parseWhoamiResponse({ actor: null, session: { kind: "expired" } }),
|
||||
{ actor: null, session: { kind: "expired" } },
|
||||
parseWhoamiResponse({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
}),
|
||||
{
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("whoami and completion require explicit consistent authentication references", () => {
|
||||
const user = {
|
||||
user_id: "u",
|
||||
account_id: "a",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
};
|
||||
const authenticated = {
|
||||
actor: { ...user, auth_method: "browser_session" },
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
} as const;
|
||||
for (const reference of [undefined, null, "", 42, "x".repeat(16385)]) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWhoamiResponse({
|
||||
...authenticated,
|
||||
authentication_reference: reference,
|
||||
}),
|
||||
"invalid authenticated reference",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseAuthUserResponse({ user, authentication_reference: reference }),
|
||||
"invalid issuance reference",
|
||||
);
|
||||
}
|
||||
for (const kind of ["anonymous", "expired"]) {
|
||||
assertThrows(
|
||||
() => parseWhoamiResponse({ actor: null, session: { kind } }),
|
||||
"missing anonymous reference",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWhoamiResponse({
|
||||
actor: null,
|
||||
authentication_reference: "issued",
|
||||
session: { kind },
|
||||
}),
|
||||
"anonymous reference must be null",
|
||||
);
|
||||
}
|
||||
const completion = { user, authentication_reference: "issued" };
|
||||
assertEquals(parseAuthUserResponse(completion), completion);
|
||||
assertEquals(
|
||||
parseWhoamiResponse({
|
||||
...authenticated,
|
||||
authentication_reference: "issued",
|
||||
}),
|
||||
{
|
||||
actor: authenticated.actor,
|
||||
authentication_reference: "issued",
|
||||
session: authenticated.session,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("bounded future transports do not discard a created credential and required crypto bytes stay validated", () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]).buffer;
|
||||
const credential = {
|
||||
id: "AQID",
|
||||
rawId: bytes,
|
||||
type: "public-key",
|
||||
getClientExtensionResults: () => ({
|
||||
credProps: { rk: true },
|
||||
prf: { results: { first: bytes } },
|
||||
largeBlob: { blob: bytes },
|
||||
}),
|
||||
response: {
|
||||
clientDataJSON: bytes,
|
||||
attestationObject: bytes,
|
||||
getTransports: () => ["internal", "hybrid", "future-bounded-transport"],
|
||||
},
|
||||
};
|
||||
const converted = registrationCredentialToJson(credential) as {
|
||||
response: { transports: string[] };
|
||||
clientExtensionResults: unknown;
|
||||
};
|
||||
assertEquals(converted.response.transports, ["internal", "hybrid"]);
|
||||
assertEquals(converted.clientExtensionResults, { credProps: { rk: true } });
|
||||
assertThrows(
|
||||
() =>
|
||||
registrationCredentialToJson({
|
||||
...credential,
|
||||
response: { ...credential.response, clientDataJSON: "not-binary" },
|
||||
}),
|
||||
"required client data remains binary",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
registrationCredentialToJson({
|
||||
...credential,
|
||||
response: {
|
||||
...credential.response,
|
||||
getTransports: () => ["x".repeat(16385)],
|
||||
},
|
||||
}),
|
||||
"transport bound remains enforced",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
registrationCredentialToJson({
|
||||
...credential,
|
||||
getClientExtensionResults: () => ({ credProps: { rk: bytes } }),
|
||||
}),
|
||||
"supported JSON extension never silently converts binary to an empty object",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
DeviceLoginPollStatus,
|
||||
DeviceLoginStartResponse,
|
||||
LogoutResponse,
|
||||
PasskeyListResponse,
|
||||
PasskeySummary,
|
||||
PasskeyAdditionResultResponse,
|
||||
PasskeyLoginOptionsResponse,
|
||||
PasskeyRegistrationOptionsResponse,
|
||||
RequestActor,
|
||||
@@ -21,6 +24,9 @@ export type {
|
||||
AuthPublicConfig,
|
||||
DeviceLoginPollResponse,
|
||||
DeviceLoginStartResponse,
|
||||
PasskeyListResponse,
|
||||
PasskeySummary,
|
||||
PasskeyAdditionResultResponse,
|
||||
PasskeyLoginOptionsResponse,
|
||||
PasskeyRegistrationOptionsResponse,
|
||||
RequestActor,
|
||||
@@ -37,8 +43,10 @@ const MAX_DEVICE_LOGIN_SECONDS = 24 * 60 * 60;
|
||||
const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/;
|
||||
|
||||
export class AuthPayloadError extends Error {}
|
||||
|
||||
function invalid(path: string, reason: string): never {
|
||||
throw new Error(`Invalid auth payload: ${path} ${reason}.`);
|
||||
throw new AuthPayloadError(`Invalid auth payload: ${path} ${reason}.`);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
@@ -119,6 +127,7 @@ function boundedJson(
|
||||
if (budget.remainingNodes < 0) invalid(path, "has too many values");
|
||||
if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested");
|
||||
if (value === null || typeof value === "boolean") return;
|
||||
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) invalid(path, "contains unsupported binary data");
|
||||
if (typeof value === "string") {
|
||||
boundedString(value, path, { allowEmpty: true });
|
||||
budget.remainingStringUnits -= value.length;
|
||||
@@ -205,10 +214,10 @@ export function bufferToBase64Url(value: ArrayBuffer): string {
|
||||
function parseAuthenticatorTransport(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): AuthenticatorTransport {
|
||||
): AuthenticatorTransport | null {
|
||||
const transport = boundedString(value, path);
|
||||
if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) {
|
||||
invalid(path, "contains an unknown authenticator transport");
|
||||
return null;
|
||||
}
|
||||
return transport as AuthenticatorTransport;
|
||||
}
|
||||
@@ -229,9 +238,10 @@ function parseCredentialDescriptor(
|
||||
if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) {
|
||||
invalid(`${path}.transports`, "has too many items");
|
||||
}
|
||||
return descriptor.transports.map((transport, index) =>
|
||||
parseAuthenticatorTransport(transport, `${path}.transports[${index}]`)
|
||||
);
|
||||
return descriptor.transports.flatMap((transport, index) => {
|
||||
const parsed = parseAuthenticatorTransport(transport, `${path}.transports[${index}]`);
|
||||
return parsed === null ? [] : [parsed];
|
||||
});
|
||||
})();
|
||||
return {
|
||||
type: "public-key",
|
||||
@@ -463,7 +473,8 @@ function parseActor(value: unknown, path: string): RequestActor {
|
||||
|
||||
export function parseWhoamiResponse(value: unknown): WhoamiResponse {
|
||||
const response = asRecord(value, "whoami");
|
||||
requireExactKeys(response, "whoami", ["actor", "session"]);
|
||||
requireExactKeys(response, "whoami", ["actor", "session", "authentication_reference"]);
|
||||
const authentication_reference = optionalString(response.authentication_reference, "whoami.authentication_reference");
|
||||
const actor = response.actor === null
|
||||
? null
|
||||
: parseActor(response.actor, "whoami.actor");
|
||||
@@ -487,7 +498,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
|
||||
"must identify the server session expiry",
|
||||
);
|
||||
}
|
||||
return { actor, session: { kind: "authenticated", expires_at: expiry } };
|
||||
if (authentication_reference === null) invalid("whoami", "authenticated session requires comparison evidence");
|
||||
return { actor, authentication_reference, session: { kind: "authenticated", expires_at: expiry } };
|
||||
}
|
||||
requireExactKeys(session, "whoami.session", ["kind"]);
|
||||
if (
|
||||
@@ -496,7 +508,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
|
||||
) {
|
||||
invalid("whoami", "inconsistent actor or session state");
|
||||
}
|
||||
return { actor: null, session: { kind: session.kind } };
|
||||
if (authentication_reference !== null) invalid("whoami", "anonymous session cannot identify authentication");
|
||||
return { actor: null, authentication_reference: null, session: { kind: session.kind } };
|
||||
}
|
||||
|
||||
export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
|
||||
@@ -537,10 +550,38 @@ export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseAuthUser(value: unknown): AuthUser {
|
||||
return parseAuthenticatedUser(value, "auth_user.user");
|
||||
}
|
||||
|
||||
export function parseAuthUserResponse(value: unknown): PasskeyUserResponse {
|
||||
const response = asRecord(value, "auth_user");
|
||||
requireExactKeys(response, "auth_user", ["user"]);
|
||||
return { user: parseAuthenticatedUser(response.user, "auth_user.user") };
|
||||
requireExactKeys(response, "auth_user", ["user", "authentication_reference"]);
|
||||
return { user: parseAuthenticatedUser(response.user, "auth_user.user"), authentication_reference: boundedString(response.authentication_reference, "auth_user.authentication_reference") };
|
||||
}
|
||||
|
||||
function parsePasskeySummary(value: unknown): PasskeySummary {
|
||||
const key = asRecord(value, "passkey_summary");
|
||||
requireExactKeys(key, "passkey_summary", ["key_reference", "created_at", "last_used_at", "normal_login_verified_at"]);
|
||||
const date = (value: unknown): string => {
|
||||
const text = boundedString(value, "passkey_summary.timestamp");
|
||||
if (!Number.isFinite(Date.parse(text))) invalid("passkey_summary.timestamp", "must be a timestamp");
|
||||
return text;
|
||||
};
|
||||
return { key_reference: boundedString(key.key_reference, "passkey_summary.key_reference"), created_at: date(key.created_at), last_used_at: key.last_used_at === null ? null : date(key.last_used_at), normal_login_verified_at: key.normal_login_verified_at === null ? null : date(key.normal_login_verified_at) };
|
||||
}
|
||||
export function parsePasskeyList(value: unknown): PasskeyListResponse {
|
||||
const list = asRecord(value, "passkey_list");
|
||||
requireExactKeys(list, "passkey_list", ["user", "keys"]);
|
||||
if (!Array.isArray(list.keys) || list.keys.length > MAX_AUTH_ARRAY_LENGTH) invalid("passkey_list.keys", "must be a bounded array");
|
||||
return { user: parseAuthenticatedUser(list.user, "passkey_list.user"), keys: list.keys.map(parsePasskeySummary) };
|
||||
}
|
||||
export function parseAdditionResult(value: unknown): PasskeyAdditionResultResponse {
|
||||
const result = asRecord(value, "addition_result");
|
||||
if (result.status === "unknown") { requireExactKeys(result, "addition_result", ["status"]); return { status: "unknown" }; }
|
||||
requireExactKeys(result, "addition_result", ["status", "user", "key"]);
|
||||
if (result.status !== "saved") invalid("addition_result", "contains an unknown status");
|
||||
return { status: "saved", user: parseAuthenticatedUser(result.user, "addition_result.user"), key: parsePasskeySummary(result.key) };
|
||||
}
|
||||
|
||||
export function prepareRegistrationOptions(
|
||||
@@ -583,11 +624,14 @@ function clientExtensionResults(
|
||||
const value = (credential.getClientExtensionResults as () => unknown).call(
|
||||
credential,
|
||||
);
|
||||
boundedJson(value, "credential.clientExtensionResults");
|
||||
return asRecord(
|
||||
value,
|
||||
"credential.clientExtensionResults",
|
||||
) as AuthenticationExtensionsClientOutputs;
|
||||
const output = asRecord(value, "credential.clientExtensionResults");
|
||||
// 現Serverが消費するJSON拡張だけを転送する。PRF/largeBlob等の未要求binary
|
||||
// outputsをJSON.stringifyで{}へ変えたり、未使用機能として有効化したりしない。
|
||||
const supported = Object.fromEntries(
|
||||
["appid", "credProps", "hmacSecret", "credProtect", "minPinLength"].filter(key => key in output).map(key => [key, output[key]]),
|
||||
);
|
||||
boundedJson(supported, "credential.clientExtensionResults");
|
||||
return supported as AuthenticationExtensionsClientOutputs;
|
||||
}
|
||||
|
||||
export function registrationCredentialToJson(credential: unknown): unknown {
|
||||
@@ -605,12 +649,12 @@ export function registrationCredentialToJson(credential: unknown): unknown {
|
||||
) {
|
||||
invalid("credential.response.transports", "must be a bounded array");
|
||||
}
|
||||
const transports = transportsValue.map((transport, index) =>
|
||||
parseAuthenticatorTransport(
|
||||
transport,
|
||||
`credential.response.transports[${index}]`,
|
||||
)
|
||||
);
|
||||
// transportは補助hint。Serverのwebauthn-rsも未知値をUnknownとして無視する。
|
||||
// boundedな新値を理由に、端末上で作成済みの暗号credential全体を捨てない。
|
||||
const transports = transportsValue.flatMap((value, index) => {
|
||||
const transport = boundedString(value, `credential.response.transports[${index}]`);
|
||||
return ["ble", "hybrid", "internal", "nfc", "usb"].includes(transport) ? [transport] : [];
|
||||
});
|
||||
const authenticatorAttachment = optionalString(
|
||||
core.authenticatorAttachment,
|
||||
"credential.authenticatorAttachment",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { PasskeyAdditionResultRequest } from "#lib/generated/auth-api.ts";
|
||||
import type { WhoamiResponse } from "./model.ts";
|
||||
|
||||
export type AuthStage =
|
||||
| "session-check"
|
||||
| "options"
|
||||
| "authenticator"
|
||||
| "conversion"
|
||||
| "before-complete"
|
||||
| "complete"
|
||||
| "confirmation";
|
||||
export type AuthFailure =
|
||||
| "cancelled-or-unavailable"
|
||||
| "excluded"
|
||||
| "unsupported"
|
||||
| "environment"
|
||||
| "conversion"
|
||||
| "session-changed"
|
||||
| "rejected"
|
||||
| "transport"
|
||||
| "invalid-response"
|
||||
| "stopped";
|
||||
export type AuthOutcome =
|
||||
| "not-sent"
|
||||
| "rejected"
|
||||
| "unknown"
|
||||
| "saved-session-unconfirmed";
|
||||
|
||||
// 診断は固定分類だけを持つ。cause、例外本文、HTTP body、credentialやceremony IDを保持しない。
|
||||
export class AuthOperationError extends Error {
|
||||
constructor(
|
||||
readonly stage: AuthStage,
|
||||
readonly reason: AuthFailure,
|
||||
readonly outcome: AuthOutcome,
|
||||
readonly httpStatus?: number,
|
||||
) {
|
||||
super(`Passkey ${stage}: ${reason} (${outcome}).`);
|
||||
}
|
||||
}
|
||||
type Observer = {
|
||||
phase: (stage: AuthStage) => void;
|
||||
verifySession: () => Promise<void>;
|
||||
expected: WhoamiResponse | null;
|
||||
additionAttempt: (attempt: PasskeyAdditionResultRequest) => void;
|
||||
};
|
||||
const observers = new WeakMap<AbortSignal, Observer>();
|
||||
export function observeAuthOperation(
|
||||
signal: AbortSignal,
|
||||
observer: Observer,
|
||||
): void {
|
||||
observers.set(signal, observer);
|
||||
}
|
||||
export function recordAdditionAttempt(
|
||||
signal: AbortSignal | undefined,
|
||||
attempt: PasskeyAdditionResultRequest,
|
||||
): void {
|
||||
if (signal) observers.get(signal)?.additionAttempt(attempt);
|
||||
}
|
||||
export function expectedAuthSession(
|
||||
signal?: AbortSignal,
|
||||
): WhoamiResponse | null {
|
||||
return signal ? observers.get(signal)?.expected ?? null : null;
|
||||
}
|
||||
export async function authCheckpoint(
|
||||
signal: AbortSignal | undefined,
|
||||
stage: AuthStage,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const observer = signal ? observers.get(signal) : undefined;
|
||||
observer?.phase(stage);
|
||||
if (stage === "options" || stage === "before-complete") {
|
||||
await observer?.verifySession();
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
export function classifyAuthFailure(
|
||||
error: unknown,
|
||||
stage: AuthStage,
|
||||
sent: boolean,
|
||||
): AuthOperationError {
|
||||
if (error instanceof AuthOperationError) return error;
|
||||
const outcome = sent ? "unknown" : "not-sent";
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
(stage === "authenticator" || error.name === "AbortError")
|
||||
) {
|
||||
switch (error.name) {
|
||||
case "AbortError":
|
||||
return new AuthOperationError(stage, "stopped", outcome);
|
||||
case "InvalidStateError":
|
||||
return new AuthOperationError(stage, "excluded", outcome);
|
||||
case "NotAllowedError":
|
||||
return new AuthOperationError(
|
||||
stage,
|
||||
"cancelled-or-unavailable",
|
||||
outcome,
|
||||
);
|
||||
case "NotSupportedError":
|
||||
return new AuthOperationError(stage, "unsupported", outcome);
|
||||
case "SecurityError":
|
||||
return new AuthOperationError(stage, "environment", outcome);
|
||||
}
|
||||
}
|
||||
return new AuthOperationError(
|
||||
stage,
|
||||
stage === "conversion" ? "conversion" : "transport",
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
export function operationErrorMessage(error: AuthOperationError): string {
|
||||
const reason: Record<AuthFailure, string> = {
|
||||
"cancelled-or-unavailable":
|
||||
"The browser did not complete verification. It may have been cancelled, timed out, or found no usable key; the browser does not distinguish these reasons.",
|
||||
excluded:
|
||||
"The authenticator reported an existing key (duplicate exclusion). Keep the older key and choose a different device, security key, or passkey provider; do not delete your only working key.",
|
||||
unsupported:
|
||||
"This browser or key cannot complete this passkey operation. Use a supported device that can save passkeys and verify with a PIN or biometrics.",
|
||||
environment:
|
||||
"The browser blocked this operation because of an origin, security, or environment constraint. Check that you are using the intended secure site; the exact cause is not known.",
|
||||
conversion:
|
||||
"The device may have created a key, but its response could not be converted for the server. Registration was not sent. Keep existing keys; a device-side entry alone does not mean it was saved to this account.",
|
||||
"session-changed":
|
||||
"The account or session changed, or could not be confirmed. The result cannot be applied to the previously displayed account.",
|
||||
rejected:
|
||||
"The server rejected the request or verification. A challenge may be invalid or expired. Check your account and the operation result before starting a new ceremony.",
|
||||
transport: "Communication could not be confirmed.",
|
||||
"invalid-response": "The server response could not be confirmed.",
|
||||
stopped: "Operation stopped.",
|
||||
};
|
||||
const outcome = error.outcome === "not-sent"
|
||||
? "Completion was not sent."
|
||||
: error.outcome === "rejected"
|
||||
? "The server did not accept completion."
|
||||
: error.outcome === "saved-session-unconfirmed"
|
||||
? "The server confirmed the operation, but the expected browser session is not confirmed."
|
||||
: "Completion may already have reached the server. Its result is unknown; check the operation result before adding another key. Stopping the browser does not undo a submitted request.";
|
||||
return `${reason[error.reason]} ${outcome} [${error.stage}/${error.reason}${
|
||||
error.httpStatus ? `/HTTP-${error.httpStatus}` : ""
|
||||
}]`;
|
||||
}
|
||||
@@ -59,7 +59,7 @@ for (const kind of ["anonymous", "expired"] as const) {
|
||||
await browserSession.ensure();
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
fetcher.mockResolvedValueOnce(
|
||||
Response.json({ actor: null, session: { kind } }),
|
||||
Response.json({ authentication_reference: null, actor: null, session: { kind } }),
|
||||
);
|
||||
await expect(requirePageSession(target)).rejects.toMatchObject({
|
||||
status: 307,
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
// @vitest-environment happy-dom
|
||||
// BrowserAuth→本番adapterの競合/結果判定。HTTPと認証器のみをgate付きで代替し、
|
||||
// Server検証/保存の根拠にはしない。秘密の本文は診断へ保存しない。
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { BrowserAuth } from "./browser-session.svelte.ts";
|
||||
import { browserSession, sessionMutation } from "./session-state.ts";
|
||||
import { addPasskey, loginWithPasskey, logout } from "./api.ts";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
loginOptions,
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
|
||||
const owners: BrowserAuth[] = [];
|
||||
afterEach(() => {
|
||||
for (const owner of owners.splice(0)) owner.dispose();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
function gate<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function identity(user: typeof authUser | null) {
|
||||
return Response.json({
|
||||
authentication_reference: user ? "fixture-authentication" : null,
|
||||
actor: user ? { ...user, auth_method: "browser_session" } : null,
|
||||
session: user
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
});
|
||||
}
|
||||
function harness() {
|
||||
const state = { user: authUser as typeof authUser | null, completed: false };
|
||||
const create = vi.fn<CredentialsContainer["create"]>().mockResolvedValue(
|
||||
credentialFixture(),
|
||||
);
|
||||
const get = vi.fn<CredentialsContainer["get"]>().mockResolvedValue(
|
||||
credentialFixture(true),
|
||||
);
|
||||
vi.stubGlobal("navigator", { credentials: { create, get } });
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") return identity(state.user);
|
||||
if (path.endsWith("/options")) {
|
||||
return Response.json(
|
||||
path.includes("login") ? loginOptions : registrationOptions,
|
||||
);
|
||||
}
|
||||
if (path.endsWith("/complete")) {
|
||||
state.completed = true;
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/logout") {
|
||||
state.user = null;
|
||||
return Response.json({ status: "logged_out" });
|
||||
}
|
||||
throw new Error("Unexpected synthetic endpoint");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const auth = new BrowserAuth();
|
||||
owners.push(auth);
|
||||
return { state, create, get, fetcher, auth };
|
||||
}
|
||||
function savedAddition() {
|
||||
return Response.json({
|
||||
status: "saved",
|
||||
user: authUser,
|
||||
key: {
|
||||
key_reference: "safe-opaque-reference",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
normal_login_verified_at: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
async function recoveryHarness() {
|
||||
const context = harness();
|
||||
await context.auth.refresh();
|
||||
context.auth.additionAttempt = {
|
||||
challenge_id: "synthetic-addition",
|
||||
expected_user_id: authUser.user_id,
|
||||
expected_account_id: authUser.account_id,
|
||||
expected_authentication_reference: "original-operation-reference",
|
||||
};
|
||||
return context;
|
||||
}
|
||||
for (const phase of ["initial-whoami", "receipt", "final-whoami"] as const) {
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
test(`recovery ${phase} rejects a shared ${reason} stamp before event delivery`, async () => {
|
||||
const { auth, fetcher } = await recoveryHarness();
|
||||
const started = gate<void>(), response = gate<Response>();
|
||||
let whoami = 0, receiptReads = 0;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
++whoami;
|
||||
if (phase === (whoami === 1 ? "initial-whoami" : "final-whoami")) {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return identity(authUser);
|
||||
}
|
||||
if (path.endsWith("/addition/result")) {
|
||||
++receiptReads;
|
||||
expect(
|
||||
JSON.parse(String(init?.body)).expected_authentication_reference,
|
||||
)
|
||||
.toBe("original-operation-reference");
|
||||
if (phase === "receipt") {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return savedAddition();
|
||||
}
|
||||
throw new Error("Unexpected recovery endpoint");
|
||||
});
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
const checking = auth.checkAddition();
|
||||
await started.promise;
|
||||
await auth.checkAddition(); // busy recovery cannot issue a second read
|
||||
const changed = JSON.stringify({
|
||||
id: `recovery-${phase}-${reason}`,
|
||||
reason,
|
||||
});
|
||||
localStorage.setItem("yoi.browser-auth-generation", changed);
|
||||
expect(browserSession.authenticationStamp).not.toBe(stamp);
|
||||
expect(browserSession.authenticationGeneration).toBe(generation);
|
||||
response.resolve(
|
||||
phase === "receipt" ? savedAddition() : identity(authUser),
|
||||
);
|
||||
await checking;
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(auth.additionAttempt?.expected_authentication_reference)
|
||||
.toBe("original-operation-reference");
|
||||
expect(auth.status).not.toContain("current session was checked");
|
||||
expect(auth.error).toContain("does not prove failure");
|
||||
expect(receiptReads).toBe(phase === "initial-whoami" ? 0 : 1);
|
||||
expect(whoami).toBe(phase === "final-whoami" ? 2 : 1);
|
||||
expect(localStorage.getItem("yoi.browser-auth-generation")).toBe(changed);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
)
|
||||
.toBe(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const phase of ["receipt", "final-whoami"] as const) {
|
||||
for (const invalidation of ["cancel", "dispose", "generation"] as const) {
|
||||
test(`recovery ${phase} ignores a delayed saved response after ${invalidation}`, async () => {
|
||||
const { auth, fetcher } = await recoveryHarness();
|
||||
const started = gate<void>(), response = gate<Response>();
|
||||
let whoami = 0;
|
||||
fetcher.mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
if (++whoami === 2 && phase === "final-whoami") {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return identity(authUser);
|
||||
}
|
||||
if (path.endsWith("/addition/result")) {
|
||||
if (phase === "receipt") {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return savedAddition();
|
||||
}
|
||||
throw new Error("Unexpected recovery endpoint");
|
||||
});
|
||||
const checking = auth.checkAddition();
|
||||
await started.promise;
|
||||
if (invalidation === "cancel") auth.cancel();
|
||||
else if (invalidation === "dispose") auth.dispose();
|
||||
else sessionMutation();
|
||||
response.resolve(
|
||||
phase === "receipt" ? savedAddition() : identity(authUser),
|
||||
);
|
||||
await checking;
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.status).not.toContain("current session was checked");
|
||||
expect(whoami).toBe(phase === "receipt" ? 1 : 2);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
)
|
||||
.toBe(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
test("failed recheck cannot retain repeat-add permission from an older saved confirmation", async () => {
|
||||
const { auth, fetcher } = await recoveryHarness();
|
||||
auth.additionSaved = true;
|
||||
fetcher.mockResolvedValue(identity(null));
|
||||
await auth.checkAddition();
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.error).toContain("does not prove failure");
|
||||
});
|
||||
test("unknown receipt retains the unresolved attempt and never permits blind repetition", async () => {
|
||||
const { auth, fetcher } = await recoveryHarness();
|
||||
fetcher.mockImplementation(async (input) =>
|
||||
String(input).endsWith("/addition/result")
|
||||
? Response.json({ status: "unknown" })
|
||||
: identity(authUser)
|
||||
);
|
||||
await auth.checkAddition();
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.status).toContain("still unknown");
|
||||
expect(auth.error).toBe("");
|
||||
});
|
||||
for (const outcome of ["anonymous", "other-actor", "unavailable"] as const) {
|
||||
test(`addition response is not Added when confirming whoami is ${outcome}`, async () => {
|
||||
const { auth, state, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
if (state.completed && String(input) === "/api/auth/whoami") {
|
||||
if (outcome === "unavailable") {
|
||||
throw new TypeError("private network detail");
|
||||
}
|
||||
return identity(
|
||||
outcome === "anonymous" ? null : {
|
||||
...authUser,
|
||||
user_id: "bob-user",
|
||||
account_id: "bob-account",
|
||||
handle: "bob",
|
||||
},
|
||||
);
|
||||
}
|
||||
return await request(input, init);
|
||||
});
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.status).not.toBe("Added");
|
||||
expect(auth.error).not.toContain("private network detail");
|
||||
});
|
||||
}
|
||||
for (const invalidation of ["logout", "dispose", "account-change"] as const) {
|
||||
test(`${invalidation} during native prompt never posts a delayed login credential`, async () => {
|
||||
const { auth, state, get, fetcher } = harness();
|
||||
state.user = null;
|
||||
await auth.refresh();
|
||||
const prompted = gate<void>();
|
||||
const credential = gate<Credential | null>();
|
||||
get.mockImplementation(() => {
|
||||
prompted.resolve();
|
||||
return credential.promise;
|
||||
});
|
||||
const run = auth.run("Waiting", "Logged in", loginWithPasskey);
|
||||
await prompted.promise;
|
||||
if (invalidation === "dispose") auth.dispose();
|
||||
else if (invalidation === "account-change") sessionMutation();
|
||||
else {
|
||||
const signout = new BrowserAuth();
|
||||
owners.push(signout);
|
||||
await signout.run("Logging out", "Logged out", logout, "logout");
|
||||
}
|
||||
credential.resolve(credentialFixture(true));
|
||||
expect(await run).toBe(false);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) =>
|
||||
String(path).endsWith("/login/complete")
|
||||
),
|
||||
).toBe(false);
|
||||
expect(state.completed).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
const bob = {
|
||||
...authUser,
|
||||
user_id: "bob-user",
|
||||
account_id: "bob-account",
|
||||
handle: "bob",
|
||||
};
|
||||
for (const changeAt of ["start", "before-complete"] as const) {
|
||||
test(`stale Alice with Bob cookie at ${changeAt} cannot add to Bob`, async () => {
|
||||
const { auth, state, create, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
if (changeAt === "start") state.user = bob;
|
||||
else {create.mockImplementationOnce(async () => {
|
||||
state.user = bob;
|
||||
return credentialFixture();
|
||||
});}
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(state.completed).toBe(false);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
).toBe(false);
|
||||
if (changeAt === "start") expect(create).not.toHaveBeenCalled();
|
||||
expect(auth.error).toContain("session-changed");
|
||||
});
|
||||
}
|
||||
test("same owner but different authentication binding before complete is not the expected session", async () => {
|
||||
const { auth, create, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
let changed = false;
|
||||
create.mockImplementationOnce(async () => {
|
||||
changed = true;
|
||||
return credentialFixture();
|
||||
});
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami" && changed
|
||||
? Response.json({
|
||||
...(await identity(authUser).json()),
|
||||
authentication_reference: "different-binding",
|
||||
})
|
||||
: request(input, init)
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
).toBe(false);
|
||||
});
|
||||
test("returned owner alone does not prove the issued browser session", async () => {
|
||||
const { auth, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input).endsWith("/complete")
|
||||
? Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "issued-but-not-current",
|
||||
})
|
||||
: request(input, init)
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.error).toContain("expected browser session is not confirmed");
|
||||
});
|
||||
for (
|
||||
const reason of [
|
||||
"InvalidStateError",
|
||||
"NotAllowedError",
|
||||
"SecurityError",
|
||||
"NotSupportedError",
|
||||
] as const
|
||||
) {
|
||||
test(`native ${reason} has a safe stage-specific classification without raw exceptions`, async () => {
|
||||
const { auth, create } = harness();
|
||||
await auth.refresh();
|
||||
create.mockRejectedValueOnce(
|
||||
new DOMException("RAW-CREDENTIAL-TOKEN-COOKIE", reason),
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.error).not.toContain("RAW-CREDENTIAL-TOKEN-COOKIE");
|
||||
expect(auth.error).toContain("authenticator/");
|
||||
expect(auth.error).toContain("Completion was not sent");
|
||||
if (reason === "InvalidStateError") {
|
||||
expect(auth.error).toContain("duplicate exclusion");
|
||||
}
|
||||
if (reason === "NotAllowedError") {
|
||||
expect(auth.error).toContain("does not distinguish");
|
||||
}
|
||||
});
|
||||
}
|
||||
test("device creation followed by conversion failure never sends complete or claims registration", async () => {
|
||||
const { auth, create, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
create.mockResolvedValueOnce(
|
||||
{
|
||||
...credentialFixture(),
|
||||
rawId: "private-invalid-value",
|
||||
} as unknown as Credential,
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.error).toContain("conversion");
|
||||
expect(auth.error).toContain("device may have created");
|
||||
expect(auth.error).not.toContain("private-invalid-value");
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
).toBe(false);
|
||||
});
|
||||
test("duplicate operations are fenced and explicit cancellation stops late credentials before submission", async () => {
|
||||
const { auth, create, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const started = gate<void>(), credential = gate<Credential | null>();
|
||||
create.mockImplementationOnce(() => {
|
||||
started.resolve();
|
||||
return credential.promise;
|
||||
});
|
||||
const first = auth.run("Waiting", "Added", addPasskey);
|
||||
await started.promise;
|
||||
const duplicate = vi.fn(async () => ({}));
|
||||
expect(await auth.run("Duplicate", "Wrong", duplicate)).toBe(false);
|
||||
expect(duplicate).not.toHaveBeenCalled();
|
||||
auth.cancel();
|
||||
credential.resolve(credentialFixture());
|
||||
expect(await first).toBe(false);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
).toBe(false);
|
||||
});
|
||||
test("abort after complete dispatch remains an unknown submitted addition with a recoverable operation", async () => {
|
||||
const { auth, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const started = gate<void>(), response = gate<Response>();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
if (String(input).endsWith("/complete")) {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
const run = auth.run("Waiting", "Added", addPasskey);
|
||||
await started.promise;
|
||||
auth.cancel();
|
||||
response.resolve(
|
||||
Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
}),
|
||||
);
|
||||
expect(await run).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.error).toContain("result is unknown");
|
||||
expect(auth.error).not.toContain("Completion was not sent");
|
||||
});
|
||||
for (
|
||||
const current of [
|
||||
"original-session",
|
||||
"cookie-updated",
|
||||
"anonymous",
|
||||
"bob",
|
||||
"unavailable",
|
||||
] as const
|
||||
) {
|
||||
test(`lost completion body with ${current} distinguishes saved, session evidence and unavailable authority`, async () => {
|
||||
const { auth, state, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
let dispatched = false, resultReads = 0;
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path.endsWith("/complete")) {
|
||||
dispatched = true;
|
||||
throw new TypeError("RAW-HTTP-BODY-TOKEN");
|
||||
}
|
||||
if (path === "/api/auth/whoami" && dispatched) {
|
||||
if (current === "unavailable") throw new TypeError("RAW-WHOAMI-ERROR");
|
||||
if (current === "anonymous") return identity(null);
|
||||
if (current === "bob") return identity(bob);
|
||||
if (current === "cookie-updated") {
|
||||
return Response.json({
|
||||
...(await identity(authUser).json()),
|
||||
authentication_reference: "new-live-session",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (path.endsWith("/addition/result")) {
|
||||
++resultReads;
|
||||
expect(JSON.parse(String(init?.body)).expected_authentication_reference)
|
||||
.toBe("fixture-authentication");
|
||||
return Response.json({
|
||||
status: "saved",
|
||||
user: authUser,
|
||||
key: {
|
||||
key_reference: "safe-opaque-reference",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
normal_login_verified_at: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.error).not.toContain("RAW-");
|
||||
expect(auth.error).toContain("result is unknown");
|
||||
await auth.checkAddition();
|
||||
if (current === "original-session" || current === "cookie-updated") {
|
||||
expect(auth.additionSaved).toBe(true);
|
||||
expect(auth.status).toContain("saved a key");
|
||||
expect(auth.status).toContain(
|
||||
"session issued by completion is not confirmed",
|
||||
);
|
||||
expect(resultReads).toBe(1);
|
||||
} else {
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(resultReads).toBe(0);
|
||||
expect(auth.error).toContain("does not prove failure");
|
||||
}
|
||||
expect(state.completed).toBe(false); // synthetic boundary never acts as the real store
|
||||
});
|
||||
}
|
||||
|
||||
test("storage mutation arriving before the storage event fences prompt completion even if whoami stays anonymous", async () => {
|
||||
const { auth, state, get, fetcher } = harness();
|
||||
state.user = null;
|
||||
await auth.refresh();
|
||||
get.mockImplementationOnce(async () => {
|
||||
localStorage.setItem(
|
||||
"yoi.browser-auth-generation",
|
||||
JSON.stringify({ id: "another-tab", reason: "logout" }),
|
||||
);
|
||||
return credentialFixture(true);
|
||||
});
|
||||
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(false);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) =>
|
||||
String(path).endsWith("/login/complete")
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
for (const phase of ["options", "complete"] as const) {
|
||||
test(`malformed ${phase} response records the stage without leaking body or inventing completion`, async () => {
|
||||
const { auth, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) =>
|
||||
String(input).endsWith(`/${phase}`)
|
||||
? Promise.resolve(
|
||||
new Response("RAW-CHALLENGE-COOKIE", {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
: request(input, init)
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.error).toContain(`${phase}/invalid-response`);
|
||||
expect(auth.error).not.toContain("RAW-CHALLENGE-COOKIE");
|
||||
expect(auth.additionAttempt === null).toBe(phase === "options");
|
||||
if (phase === "options") {
|
||||
expect(auth.error).toContain("Completion was not sent");
|
||||
} else expect(auth.error).toContain("result is unknown");
|
||||
});
|
||||
}
|
||||
for (
|
||||
const code of [
|
||||
"passkey_authentication_changed",
|
||||
"webauthn_registration_verification_failed",
|
||||
] as const
|
||||
) {
|
||||
test(`HTTP rejection classifies allowlisted ${code} without retaining HTTP body`, async () => {
|
||||
const { auth, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) =>
|
||||
String(input).endsWith("/complete")
|
||||
? Promise.resolve(
|
||||
Response.json({
|
||||
message: `${code}: RAW-EXCEPTION-BODY`,
|
||||
secret: "RAW-TOKEN",
|
||||
}, { status: 401 }),
|
||||
)
|
||||
: request(input, init)
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.error).not.toContain("RAW-");
|
||||
expect(auth.error).toContain(
|
||||
code === "passkey_authentication_changed"
|
||||
? "session-changed"
|
||||
: "rejected",
|
||||
);
|
||||
expect(auth.error).toContain("HTTP-401");
|
||||
expect(auth.error).not.toContain("Completion was not sent");
|
||||
});
|
||||
}
|
||||
|
||||
test("successful logout never marks an earlier unknown addition as saved", async () => {
|
||||
const { auth, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) =>
|
||||
String(input).endsWith("/complete")
|
||||
? Promise.reject(new TypeError("response lost"))
|
||||
: request(input, init)
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
});
|
||||
|
||||
test("explicit login from stale expired display remains allowed after another tab cleared the cookie", async () => {
|
||||
const { auth, state, fetcher } = harness();
|
||||
state.user = null;
|
||||
fetcher.mockResolvedValueOnce(
|
||||
Response.json({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
}),
|
||||
);
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
if (String(input).endsWith("/complete")) state.user = authUser;
|
||||
return await request(input, init);
|
||||
});
|
||||
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(true);
|
||||
expect(auth.status).toBe("Logged in");
|
||||
});
|
||||
test("explicit login from expired display does not replace another tab's authenticated account", async () => {
|
||||
const { auth, state, fetcher, get } = harness();
|
||||
fetcher.mockResolvedValueOnce(
|
||||
Response.json({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
}),
|
||||
);
|
||||
await auth.refresh();
|
||||
state.user = bob;
|
||||
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(false);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(state.completed).toBe(false);
|
||||
});
|
||||
@@ -10,7 +10,22 @@ function assertEquals(
|
||||
);
|
||||
}
|
||||
}
|
||||
import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts";
|
||||
import {
|
||||
authEntryUrl,
|
||||
isAuthPage,
|
||||
isPublicPage,
|
||||
loginUrl,
|
||||
safeReturnTo,
|
||||
} from "./routes.ts";
|
||||
|
||||
Deno.test("account uses the authenticated app shell, not the standalone auth entry shell", () => {
|
||||
assertEquals(String(isAuthPage("/account")), "false");
|
||||
assertEquals(String(isPublicPage("/account")), "false");
|
||||
for (const path of ["/login", "/register", "/activate", "/login/device"]) {
|
||||
assertEquals(String(isAuthPage(path)), "true", path);
|
||||
assertEquals(String(isPublicPage(path)), "true", path);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("return destination preserves query and fragment only on known app screens", () => {
|
||||
for (
|
||||
|
||||
@@ -80,8 +80,9 @@ export function isPublicPage(path: string): boolean {
|
||||
.includes(path);
|
||||
}
|
||||
|
||||
// 認証入口は独立したshellを使う。本人用/accountは通常のナビゲーションを残す。
|
||||
export function isAuthPage(path: string): boolean {
|
||||
return ["/login", "/register", "/account", "/activate", "/login/device"]
|
||||
return ["/login", "/register", "/activate", "/login/device"]
|
||||
.includes(path);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ test("active expired state removes private content and preserves browser query a
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json({ actor: null, session: { kind: "expired" } })
|
||||
Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
|
||||
),
|
||||
);
|
||||
await browserSession.refresh();
|
||||
@@ -62,7 +62,7 @@ test("navigation admission does not redirect to the old page return URL", async
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json({ actor: null, session: { kind: "expired" } })
|
||||
Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
|
||||
),
|
||||
);
|
||||
await browserSession.refresh();
|
||||
|
||||
@@ -13,13 +13,14 @@ afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
const anonymous: WhoamiResponse = {
|
||||
actor: null,
|
||||
actor: null, authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
};
|
||||
const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } };
|
||||
const expired: WhoamiResponse = { actor: null, authentication_reference: null, session: { kind: "expired" } };
|
||||
const authenticated = (
|
||||
expires_at: string | null = "2099-01-01T00:00:00Z",
|
||||
): WhoamiResponse => ({
|
||||
authentication_reference: "fixture-authentication",
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
session: { kind: "authenticated", expires_at },
|
||||
});
|
||||
|
||||
@@ -17,6 +17,10 @@ export class SessionState {
|
||||
private listeners = new Set<(state: SessionSnapshot) => void>();
|
||||
private stale = true;
|
||||
|
||||
get authenticationStamp(): string | null {
|
||||
return storageStamp();
|
||||
}
|
||||
|
||||
get authenticationGeneration(): number {
|
||||
return this.generation;
|
||||
}
|
||||
@@ -38,7 +42,7 @@ export class SessionState {
|
||||
++this.generation;
|
||||
this.pending = null;
|
||||
this.stale = true;
|
||||
if (!preserveSnapshot) this.publish({ kind: "loading" });
|
||||
this.publish(preserveSnapshot ? this.snapshot : { kind: "loading" });
|
||||
}
|
||||
|
||||
// Page load owns freshness. Components mounted after it reuse that result.
|
||||
@@ -100,8 +104,9 @@ export const browserSession = new SessionState();
|
||||
|
||||
export function sessionMutation(
|
||||
reason: "authentication" | "logout" = "authentication",
|
||||
preserveSnapshot = false,
|
||||
): void {
|
||||
browserSession.invalidate();
|
||||
browserSession.invalidate(preserveSnapshot);
|
||||
const message = JSON.stringify({ id: crypto.randomUUID(), reason });
|
||||
try {
|
||||
localStorage.setItem(GENERATION_KEY, message);
|
||||
|
||||
@@ -35,6 +35,7 @@ function session() {
|
||||
|
||||
function whoami(authenticated: boolean) {
|
||||
return Response.json({
|
||||
authentication_reference: authenticated ? "fixture-authentication" : null,
|
||||
session: authenticated
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -75,7 +76,10 @@ function boundary(authenticated = false) {
|
||||
return new Response(null, { status: state.completeStatus });
|
||||
}
|
||||
state.authenticated = true;
|
||||
return Response.json({ user: authUser });
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
@@ -106,6 +110,7 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
});
|
||||
@@ -162,6 +167,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
oldResponse.resolve(whoami(true));
|
||||
@@ -169,6 +175,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
expect(auth.status).toBe("Logged out");
|
||||
@@ -183,26 +190,34 @@ test("busy login rejects duplicate operations and refresh until its post-complet
|
||||
const confirmingStarted = gate<void>();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
if (String(input) === "/api/auth/passkeys/login/options") {
|
||||
return options.promise;
|
||||
}
|
||||
if (
|
||||
String(input) === "/api/auth/whoami" &&
|
||||
paths(fetcher).includes("/api/auth/passkeys/login/complete")
|
||||
) {
|
||||
confirmingStarted.resolve();
|
||||
return confirming.promise;
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
fetcher.mockReturnValueOnce(options.promise);
|
||||
const run = auth.run(
|
||||
"Waiting",
|
||||
"Logged in",
|
||||
(signal) => loginWithPasskey(signal),
|
||||
);
|
||||
expect(auth.busy).toBe(true);
|
||||
expect(auth.status).toBe("Waiting");
|
||||
expect(auth.status).toMatch(/Checking the account/);
|
||||
const duplicate = vi.fn(async () => {});
|
||||
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
|
||||
await auth.refresh();
|
||||
expect(duplicate).not.toHaveBeenCalled();
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
// Preflight is asynchronous; wait for the options stage, not an HTTP ordinal.
|
||||
await vi.waitFor(() =>
|
||||
expect(paths(fetcher)).toContain("/api/auth/passkeys/login/options")
|
||||
);
|
||||
expect(paths(fetcher).filter((path) => path !== "/api/auth/whoami")).toEqual([
|
||||
"/api/auth/passkeys/login/options",
|
||||
]);
|
||||
options.resolve(Response.json(loginOptions));
|
||||
@@ -212,15 +227,17 @@ test("busy login rejects duplicate operations and refresh until its post-complet
|
||||
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
|
||||
await auth.refresh();
|
||||
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(2);
|
||||
.toHaveLength(4);
|
||||
confirming.resolve(whoami(true));
|
||||
expect(await run).toBe(true);
|
||||
expect(auth.busy).toBe(false);
|
||||
expect(auth.actor?.handle).toBe("alice");
|
||||
expect(auth.status).toBe("Logged in");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
@@ -236,16 +253,16 @@ for (const status of [400, 401, 410]) {
|
||||
expect(result).toBe(false);
|
||||
expect(auth.busy).toBe(false);
|
||||
expect(auth.status).toBe("");
|
||||
expect(auth.error).toMatch(
|
||||
status === 401
|
||||
? /Authentication request was rejected/
|
||||
: /The request or authentication challenge is invalid or expired/,
|
||||
);
|
||||
expect(auth.error).toMatch(/server rejected the request or verification/);
|
||||
expect(auth.error).toContain(`[complete/rejected/HTTP-${status}]`);
|
||||
expect(auth.error).toContain("server did not accept completion");
|
||||
expect(auth.session.kind).toBe("ready");
|
||||
expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
@@ -278,22 +295,25 @@ for (
|
||||
).toBe(false);
|
||||
expect(auth.error).toMatch(
|
||||
name === "NotAllowedError"
|
||||
? /cancelled or timed out/
|
||||
? /cancelled, timed out, or found no usable key/
|
||||
: name === "NotSupportedError"
|
||||
? /Completion is not confirmed/
|
||||
? /supported device/
|
||||
: name === "AbortError"
|
||||
? /Completion is not confirmed/
|
||||
: /Authentication could not be confirmed/,
|
||||
? /Operation stopped/
|
||||
: /Communication could not be confirmed/,
|
||||
);
|
||||
expect(auth.error).toContain("Completion was not sent.");
|
||||
expect(auth.error).not.toContain("secret credential detail");
|
||||
expect(auth.status).toBe("");
|
||||
expect(auth.busy).toBe(false);
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/whoami",
|
||||
@@ -314,14 +334,21 @@ test("completion does not confirm authentication when the subsequent whoami fail
|
||||
const { fetcher } = boundary();
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
let completed = false;
|
||||
fetcher.mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") return new Response(null, { status: 503 });
|
||||
if (path === "/api/auth/whoami") {
|
||||
return completed ? new Response(null, { status: 503 }) : whoami(false);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return Response.json({ user: authUser });
|
||||
completed = true;
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
@@ -385,14 +412,15 @@ test("dispose aborts WebAuthn without accepting late credentials, while shared s
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
expect(auth.status).toBe("Waiting");
|
||||
expect(auth.error).toBe("");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
]);
|
||||
expect(
|
||||
dispatch.mock.calls.filter(([event]) =>
|
||||
@@ -409,6 +437,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not
|
||||
const started = gate<AbortSignal>();
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") return whoami(false);
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
@@ -427,16 +456,22 @@ test("dispose ignores a completion response even when the HTTP boundary does not
|
||||
const signal = await started.promise;
|
||||
auth.dispose();
|
||||
expect(signal.aborted).toBe(true);
|
||||
response.resolve(Response.json({ user: authUser }));
|
||||
response.resolve(
|
||||
Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
}),
|
||||
);
|
||||
expect(await run).toBe(false);
|
||||
expect(auth.actor).toBeNull();
|
||||
expect(auth.status).toBe("Waiting");
|
||||
expect(auth.status).toBe("Confirming the operation with the server…");
|
||||
expect(auth.error).toBe("");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
]);
|
||||
expect(
|
||||
dispatch.mock.calls.filter(([event]) =>
|
||||
@@ -451,15 +486,21 @@ test("dispose during the post-operation whoami prevents its late actor from beco
|
||||
await auth.refresh();
|
||||
const response = gate<Response>();
|
||||
const started = gate<void>();
|
||||
let completed = false;
|
||||
fetcher.mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return Response.json({ user: authUser });
|
||||
completed = true;
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
if (!completed) return whoami(false);
|
||||
started.resolve();
|
||||
return await response.promise;
|
||||
}
|
||||
@@ -489,7 +530,10 @@ for (const newer of ["logout", "another-actor"] as const) {
|
||||
const started = gate<void>();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
if (
|
||||
String(input) === "/api/auth/whoami" &&
|
||||
paths(fetcher).includes("/api/auth/passkeys/login/complete")
|
||||
) {
|
||||
started.resolve();
|
||||
return confirming.promise;
|
||||
}
|
||||
@@ -509,6 +553,7 @@ for (const newer of ["logout", "another-actor"] as const) {
|
||||
sessionMutation(newer === "logout" ? "logout" : "authentication");
|
||||
fetcher.mockResolvedValueOnce(
|
||||
newer === "logout" ? whoami(false) : Response.json({
|
||||
authentication_reference: "other-authentication",
|
||||
actor: {
|
||||
...authUser,
|
||||
user_id: "other-user",
|
||||
@@ -552,7 +597,8 @@ for (const purpose of ["login", "addition"] as const) {
|
||||
purpose === "login" ? loginWithPasskey(signal) : addPasskey(signal),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(auth.error).toMatch(/cancelled or timed out/);
|
||||
expect(auth.error).toMatch(/cancelled, timed out, or found no usable key/);
|
||||
expect(auth.error).toContain("Completion was not sent.");
|
||||
expect(paths(fetcher).some((path) => path.endsWith("/complete"))).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -3025,6 +3025,128 @@ Deno.test("projectConsole mirrors live TaskCreate and TaskUpdate calls", () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
function taskCallEntry(
|
||||
callId: string,
|
||||
name = "TaskCreate",
|
||||
args: object = { subject: "Inspect tasks", description: "Task detail" },
|
||||
): Extract<SessionSnapshotEntry, { kind: "tool_call" }> {
|
||||
return {
|
||||
kind: "tool_call", entry_id: `entry:${callId}`, timestamp: 1,
|
||||
provenance: "model_output", derived_from: [],
|
||||
call_id: callId, name, arguments: JSON.stringify(args),
|
||||
};
|
||||
}
|
||||
|
||||
function taskLiveEvent(entry: ReturnType<typeof taskCallEntry>): Event {
|
||||
return { event: "tool_call_done", data: {
|
||||
id: entry.call_id, name: entry.name, arguments: entry.arguments,
|
||||
} };
|
||||
}
|
||||
|
||||
function taskCommitEvent(entry: SessionSnapshotEntry): Event {
|
||||
return { event: "session_entry_committed", data: { entry } };
|
||||
}
|
||||
|
||||
Deno.test("Tasks apply each call once across live, committed and replay events", () => {
|
||||
const first = taskCallEntry("first");
|
||||
const second = taskCallEntry("second"); // Identical arguments, distinct call.
|
||||
for (const committedFirst of [false, true]) {
|
||||
const projector = createConsoleProjector();
|
||||
for (const entry of [first, second]) {
|
||||
const events = [taskLiveEvent(entry), taskCommitEvent(entry)];
|
||||
if (committedFirst) events.reverse();
|
||||
const initial = projector.append([{ eventId: `${entry.call_id}:first`, event: events[0] }]);
|
||||
const expected = initial.tasks;
|
||||
const reconciled = projector.append([
|
||||
{ eventId: `${entry.call_id}:second`, event: events[1] },
|
||||
{ eventId: `${entry.call_id}:replay-live`, event: taskLiveEvent(entry) },
|
||||
{ eventId: `${entry.call_id}:replay-commit`, event: taskCommitEvent(entry) },
|
||||
]);
|
||||
assertEquals(reconciled.tasks, expected);
|
||||
assertEquals(initial.tasks, expected);
|
||||
}
|
||||
const result = projector.snapshot();
|
||||
assertEquals(result.tasks.map((task) => task.taskid), [1, 2]);
|
||||
assertEquals(result.taskNextId, 3);
|
||||
assertEquals(result.lines.filter((line) => line.kind === "tool").length, 2);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Tasks snapshot and segment rebuild retain call deduplication and reset old state", () => {
|
||||
const entry = taskCallEntry("retained");
|
||||
const discarded = taskCallEntry("discarded");
|
||||
const snapshot = snapshotEvent("/repo") as SnapshotEvent;
|
||||
snapshot.data.session.entries = [entry];
|
||||
for (const event of [snapshot, { event: "segment_rotated", data: { session: snapshot.data.session } } satisfies Event]) {
|
||||
const projector = createConsoleProjector();
|
||||
projector.append([
|
||||
{ eventId: "old", event: taskLiveEvent(discarded) },
|
||||
{ eventId: "live", event: taskLiveEvent(entry) },
|
||||
{ eventId: "commit", event: taskCommitEvent(entry) },
|
||||
]);
|
||||
const rebuilt = projector.append([{ eventId: "rebuild", event }]);
|
||||
const replayed = projector.append([
|
||||
{ eventId: "replayed-live", event: taskLiveEvent(entry) },
|
||||
{ eventId: "replayed-commit", event: taskCommitEvent(entry) },
|
||||
]);
|
||||
assertEquals(replayed.tasks, rebuilt.tasks);
|
||||
assertEquals(replayed.tasks.map((task) => task.taskid), [1]);
|
||||
assertEquals(replayed.taskNextId, 2);
|
||||
const fresh = projector.append([{ eventId: "new", event: taskLiveEvent(discarded) }]);
|
||||
assertEquals(fresh.tasks.map((task) => task.taskid), [1, 2]);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Tasks late call replay cannot undo updates or resurrect deleted tasks", () => {
|
||||
const create = taskCallEntry("create");
|
||||
const start = taskCallEntry("start", "TaskUpdate", { taskid: 1, status: "inprogress" });
|
||||
const complete = taskCallEntry("complete", "TaskUpdate", { taskid: 1, status: "completed" });
|
||||
const remove = taskCallEntry("remove", "TaskUpdate", { taskid: 1, status: "deleted" });
|
||||
const projector = createConsoleProjector();
|
||||
for (const entry of [create, start, complete]) {
|
||||
projector.append([{ eventId: entry.call_id, event: taskLiveEvent(entry) }]);
|
||||
}
|
||||
const completed = projector.append([{ eventId: "late-start", event: taskCommitEvent(start) }]);
|
||||
assertEquals(completed.tasks[0].status, "completed");
|
||||
const deleted = projector.append([
|
||||
{ eventId: "remove", event: taskLiveEvent(remove) },
|
||||
{ eventId: "late-create", event: taskCommitEvent(create) },
|
||||
{ eventId: "late-complete", event: taskCommitEvent(complete) },
|
||||
]);
|
||||
assertEquals(deleted.tasks, []);
|
||||
assertEquals(deleted.taskNextId, 2);
|
||||
});
|
||||
|
||||
Deno.test("Tasks deduplication does not suppress committed tool result corrections", () => {
|
||||
const entry = taskCallEntry("create");
|
||||
const result: SessionSnapshotEntry = {
|
||||
kind: "tool_result", entry_id: "result", timestamp: 2,
|
||||
provenance: "tool_output", derived_from: [], call_id: entry.call_id,
|
||||
summary: "Created task", content: "original result", is_error: false, attachments: [],
|
||||
};
|
||||
const projection = projectConsole([
|
||||
{ eventId: "live", event: taskLiveEvent(entry) },
|
||||
{ eventId: "commit", event: taskCommitEvent(entry) },
|
||||
{ eventId: "result", event: taskCommitEvent(result) },
|
||||
{ eventId: "correction", event: taskCommitEvent({ ...result, content: "corrected result" }) },
|
||||
]);
|
||||
assertEquals(projection.tasks.length, 1);
|
||||
assertEquals(projection.lines[0].toolCall?.output, "corrected result");
|
||||
});
|
||||
|
||||
Deno.test("Tasks call identities are scoped to each Worker projection", () => {
|
||||
const entry = taskCallEntry("shared-call-id");
|
||||
const worker = { session_id: "child", name: "child", parent_session_id: "parent", kind: "sub_worker" as const };
|
||||
const projection = projectConsole([
|
||||
{ eventId: "parent-live", event: taskLiveEvent(entry) },
|
||||
{ eventId: "child-live", event: { event: "internal_worker", data: { worker, event: taskLiveEvent(entry) } } },
|
||||
{ eventId: "parent-commit", event: taskCommitEvent(entry) },
|
||||
{ eventId: "child-commit", event: { event: "internal_worker", data: { worker, event: taskCommitEvent(entry) } } },
|
||||
]);
|
||||
assertEquals(projection.tasks.length, 1);
|
||||
assertEquals(projection.internalWorkers[0].console.tasks, projection.tasks);
|
||||
});
|
||||
|
||||
Deno.test("Internal Worker output stays separate and follows parent stream arrival order", () => {
|
||||
const worker = {
|
||||
session_id: "child-session",
|
||||
|
||||
@@ -201,6 +201,8 @@ export type ConsoleProjection = {
|
||||
lines: ConsoleLine[];
|
||||
tasks: ConsoleTask[];
|
||||
taskNextId: number;
|
||||
/** Task calls arrive both live and committed; apply each call only once. */
|
||||
appliedTaskCallIds: ReadonlySet<string>;
|
||||
status: string | null;
|
||||
workerState: WorkerStateSnapshot | null;
|
||||
compaction: InFlightCompaction | null;
|
||||
@@ -308,6 +310,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
|
||||
lines: [],
|
||||
tasks: [],
|
||||
taskNextId: 1,
|
||||
appliedTaskCallIds: new Set(),
|
||||
status: null,
|
||||
workerState: null,
|
||||
compaction: null,
|
||||
@@ -1153,6 +1156,7 @@ export function applyProtocolEvent(
|
||||
lines: [...projection.lines],
|
||||
tasks: [...projection.tasks],
|
||||
taskNextId: projection.taskNextId,
|
||||
appliedTaskCallIds: projection.appliedTaskCallIds,
|
||||
status: projection.status,
|
||||
workerState: projection.workerState,
|
||||
compaction: projection.compaction,
|
||||
@@ -1279,7 +1283,7 @@ export function applyProtocolEvent(
|
||||
argsStream: event.data.arguments,
|
||||
state: "running",
|
||||
});
|
||||
applyTaskTool(next, event.data.name, event.data.arguments);
|
||||
applyTaskTool(next, event.data.id, event.data.name, event.data.arguments);
|
||||
break;
|
||||
case "tool_result":
|
||||
attachToolResult(next, envelope.eventId, event.data.id, {
|
||||
@@ -1326,6 +1330,7 @@ export function applyProtocolEvent(
|
||||
next.lines = snapshot.lines;
|
||||
next.tasks = snapshot.tasks;
|
||||
next.taskNextId = snapshot.taskNextId;
|
||||
next.appliedTaskCallIds = snapshot.appliedTaskCallIds;
|
||||
appendSnapshotInFlightLines(
|
||||
next,
|
||||
event.data.in_flight?.blocks ?? [],
|
||||
@@ -1416,6 +1421,7 @@ export function applyProtocolEvent(
|
||||
next.lines = [...segment.lines, ...retainedErrors, ...retainedCompaction];
|
||||
next.tasks = segment.tasks;
|
||||
next.taskNextId = segment.taskNextId;
|
||||
next.appliedTaskCallIds = segment.appliedTaskCallIds;
|
||||
next.workerMetadata = metadataWithoutContext(next.workerMetadata);
|
||||
break;
|
||||
}
|
||||
@@ -2409,9 +2415,15 @@ function usageText(
|
||||
|
||||
function applyTaskTool(
|
||||
projection: ConsoleProjection,
|
||||
callId: string,
|
||||
name: string,
|
||||
argumentsJson: string,
|
||||
): void {
|
||||
if (name !== "TaskCreate" && name !== "TaskUpdate") return;
|
||||
if (projection.appliedTaskCallIds.has(callId)) return;
|
||||
// Fence task mutations, not tool-row reconciliation or corrected results.
|
||||
// Copy on write so previously returned projections remain unchanged.
|
||||
projection.appliedTaskCallIds = new Set(projection.appliedTaskCallIds).add(callId);
|
||||
const state = applyTaskToolCall(
|
||||
{ tasks: projection.tasks, nextTaskId: projection.taskNextId },
|
||||
name,
|
||||
@@ -2448,6 +2460,7 @@ function snapshotProjectionFromSession(
|
||||
lines: [],
|
||||
tasks: [],
|
||||
taskNextId: 1,
|
||||
appliedTaskCallIds: new Set(),
|
||||
status: null,
|
||||
workerState: null,
|
||||
compaction: null,
|
||||
@@ -2715,10 +2728,11 @@ function applyLoggedItem(
|
||||
case "tool_call": {
|
||||
const name = stringField(item, "name") ?? "Tool";
|
||||
const argumentsJson = stringField(item, "arguments") ?? "";
|
||||
const callId = stringField(item, "call_id") ?? eventId;
|
||||
upsertToolCall(
|
||||
projection,
|
||||
eventId,
|
||||
stringField(item, "call_id") ?? eventId,
|
||||
callId,
|
||||
{
|
||||
name,
|
||||
arguments: argumentsJson,
|
||||
@@ -2726,7 +2740,7 @@ function applyLoggedItem(
|
||||
state: "running",
|
||||
},
|
||||
);
|
||||
applyTaskTool(projection, name, argumentsJson);
|
||||
applyTaskTool(projection, callId, name, argumentsJson);
|
||||
break;
|
||||
}
|
||||
case "tool_result":
|
||||
|
||||
@@ -1344,7 +1344,7 @@ Deno.test("Root chrome owns global navigation and scoped override slots", async
|
||||
globalNavSections.includes("workspace.display_name") &&
|
||||
globalNavSections.includes("workspaceHref(workspace.workspace_id)") &&
|
||||
rootLayout.includes("observeDeferred(() => data.deferredCatalog") &&
|
||||
rootLayout.includes("workspaces={catalog.data?.accessibleWorkspaces ?? null}") &&
|
||||
rootLayout.includes("workspaces={page.url.pathname === '/' ? null : catalog.data?.accessibleWorkspaces ?? null}") &&
|
||||
rootLayout.includes("workspaceError={catalog.data?.workspaceCatalogError}") &&
|
||||
rootLayoutLoad.includes("deferredCatalog: settle(") &&
|
||||
rootLayoutLoad.includes("listWorkspaces(fetch)") &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { WorkspaceCatalogRecord } from "#lib/workspace/api/workspace-catalog.ts";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { loadWhoami } from "#lib/workspace/auth/api.ts";
|
||||
import { browserSession } from '#lib/workspace/auth/session-state.ts';
|
||||
import { loadHostingAccess, SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
|
||||
|
||||
let mounted = $state(false);
|
||||
@@ -12,8 +12,9 @@
|
||||
const request = ++accessRequest;
|
||||
isAdmin = false;
|
||||
try {
|
||||
const whoami = await loadWhoami();
|
||||
if (request !== accessRequest || !whoami.actor) return;
|
||||
// ページが確認したsessionを共有し、Sidebar追加でwhoamiの再取得を増やさない。
|
||||
const session = browserSession.snapshot;
|
||||
if (session.kind !== 'ready' || !session.value.actor) return;
|
||||
const access = await loadHostingAccess();
|
||||
if (request === accessRequest) isAdmin = access.is_admin;
|
||||
} catch {
|
||||
@@ -24,10 +25,12 @@
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const refresh = () => { void refreshAccess(); };
|
||||
const unsubscribe = browserSession.subscribe(refresh);
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
++accessRequest;
|
||||
unsubscribe();
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
|
||||
@@ -4,9 +4,11 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import GlobalNavSections from "./GlobalNavSections.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
import { notifySessionChanged } from "#lib/workspace/auth/hosting.ts";
|
||||
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -18,6 +20,7 @@ for (
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: actor ? "fixture-authentication" : null,
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -33,6 +36,7 @@ for (
|
||||
throw new Error(`Unexpected API ${input}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
await browserSession.refresh();
|
||||
render(GlobalNavSections, {
|
||||
currentPath: "/hosting/settings",
|
||||
workspaces: [],
|
||||
@@ -45,6 +49,7 @@ for (
|
||||
expect(link.getAttribute("href")).toBe("/hosting/settings");
|
||||
expect(link.getAttribute("aria-current")).toBe("page");
|
||||
actor = false;
|
||||
await browserSession.refresh();
|
||||
notifySessionChanged();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("link", { name: "Hosting settings" }))
|
||||
|
||||
@@ -708,6 +708,14 @@
|
||||
pointer-events: none;
|
||||
transition-delay: 0s, var(--sidebar-motion-duration);
|
||||
}
|
||||
/* Preview is visual only: closed content stays inert until committed.
|
||||
Pointer events remain with the original touch target during the drag. */
|
||||
.app-shell.sidebar-swiping .sidebar-frame {
|
||||
transform: translateX(var(--sidebar-swipe-offset));
|
||||
visibility: visible;
|
||||
transition: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sidebar-frame .sidebar-frame-content,
|
||||
.sidebar-frame.folded .sidebar-frame-content {
|
||||
transform: none;
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { swipeSidebar } from "./swipe-sidebar.ts";
|
||||
|
||||
let destroy: (() => void) | undefined;
|
||||
afterEach(() => {
|
||||
destroy?.();
|
||||
destroy = undefined;
|
||||
document.body.replaceChildren();
|
||||
document.getSelection()?.removeAllRanges();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => {
|
||||
function fixture(html = "<p>Page content</p>") {
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = html;
|
||||
const target = node.firstElementChild!;
|
||||
const panel = document.createElement("aside");
|
||||
panel.className = "sidebar-frame";
|
||||
node.append(panel);
|
||||
document.body.append(node);
|
||||
let enabled = true;
|
||||
let isOpen = initiallyOpen;
|
||||
let route = "/account";
|
||||
vi.spyOn(panel, "getBoundingClientRect").mockImplementation(() => ({
|
||||
width: 300,
|
||||
left: isOpen ? 0 : -300,
|
||||
} as DOMRect));
|
||||
const open = vi.fn((next: boolean) => {
|
||||
isOpen = next;
|
||||
action.update(options());
|
||||
});
|
||||
const options = () => ({ enabled, open: isOpen, route, setOpen: open });
|
||||
const listeners = vi.spyOn(node, "addEventListener");
|
||||
const action = swipeSidebar(node, options());
|
||||
destroy = action.destroy;
|
||||
const clientX = (x: number) => initiallyOpen ? window.innerWidth - x : x;
|
||||
const send = (
|
||||
type: string,
|
||||
x: number,
|
||||
y: number,
|
||||
time = 100,
|
||||
count = 1,
|
||||
id = 1,
|
||||
cancelable = true,
|
||||
) => {
|
||||
const touch = { identifier: id, clientX: clientX(x), clientY: y };
|
||||
const event = new Event(type, { bubbles: true, cancelable });
|
||||
Object.defineProperties(event, {
|
||||
touches: { value: type === "touchend" ? [] : Array(count).fill(touch) },
|
||||
changedTouches: { value: [touch] },
|
||||
timeStamp: { value: time },
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
const click = (x: number, y: number, detail = 1) => {
|
||||
const event = new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
detail,
|
||||
clientX: clientX(x),
|
||||
clientY: y,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
return {
|
||||
node,
|
||||
target,
|
||||
panel,
|
||||
open,
|
||||
send,
|
||||
click,
|
||||
listeners,
|
||||
flipState: () => {
|
||||
isOpen = !isOpen;
|
||||
action.update(options());
|
||||
},
|
||||
disable: () => {
|
||||
enabled = false;
|
||||
action.update(options());
|
||||
},
|
||||
navigate: () => {
|
||||
route = "/other";
|
||||
action.update(options());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("horizontal intent previews position, then settles only on release", async () => {
|
||||
const { node, open, send, listeners } = fixture();
|
||||
expect(send("touchstart", 120, 200).defaultPrevented).toBe(false);
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||
expect(send("touchmove", 150, 203, 150).defaultPrevented).toBe(true);
|
||||
expect(node.style.getPropertyValue("--sidebar-swipe-offset")).toBe(
|
||||
initiallyOpen ? "-30px" : "-270px",
|
||||
);
|
||||
send("touchmove", 270, 205, 500);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(node.style.getPropertyValue("--sidebar-swipe-offset")).toBe(
|
||||
"-150px",
|
||||
);
|
||||
send("touchend", 270, 205, 900);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
|
||||
await Promise.resolve();
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||
expect(node.style.getPropertyValue("--sidebar-swipe-offset")).toBe("");
|
||||
expect(node.style.touchAction).toBe("");
|
||||
expect(listeners.mock.calls.find(([name]) => name === "touchmove")?.[2])
|
||||
.toEqual({ passive: false });
|
||||
});
|
||||
|
||||
test("ambiguous start remains pending; horizontal lock tolerates an arcing thumb", () => {
|
||||
const { node, open, send } = fixture();
|
||||
send("touchstart", 120, 200);
|
||||
expect(send("touchmove", 132, 210, 120).defaultPrevented).toBe(false);
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||
expect(send("touchmove", 160, 214, 170).defaultPrevented).toBe(true);
|
||||
send("touchmove", 280, 260, 500);
|
||||
send("touchend", 280, 260, 700);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
|
||||
});
|
||||
|
||||
test("short fast flick commits without the former 80px requirement", () => {
|
||||
const { open, send } = fixture();
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 132, 202, 115);
|
||||
send("touchmove", 156, 203, 150);
|
||||
send("touchend", 156, 203, 160);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
|
||||
});
|
||||
|
||||
test("a noncancelable release still settles an already claimed drag", () => {
|
||||
const { open, send } = fixture();
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 280, 203, 300);
|
||||
send("touchend", 280, 203, 400, 1, 1, false);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
|
||||
});
|
||||
|
||||
test("slow long drag still commits after 700ms", () => {
|
||||
const { open, send } = fixture();
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 150, 202, 400);
|
||||
send("touchmove", 280, 203, 1500);
|
||||
send("touchend", 280, 203, 1800);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
|
||||
});
|
||||
|
||||
for (
|
||||
const [name, points] of [
|
||||
["tap", [[120, 200, 100], [122, 201, 130]]],
|
||||
["short slow drag", [[120, 200, 100], [160, 202, 500]]],
|
||||
["tiny fast flick", [[120, 200, 100], [138, 202, 120]]],
|
||||
["vertical intent", [[120, 200, 100], [125, 240, 150], [280, 242, 200]]],
|
||||
["ambiguous diagonal throughout", [[120, 200, 100], [135, 215, 130], [
|
||||
200,
|
||||
270,
|
||||
200,
|
||||
]]],
|
||||
["opposite direction", [[240, 200, 100], [210, 202, 140], [
|
||||
120,
|
||||
203,
|
||||
200,
|
||||
]]],
|
||||
["browser edge", [[10, 200, 100], [180, 201, 300]]],
|
||||
["fast motion followed by a pause", [[120, 200, 100], [160, 202, 150], [
|
||||
160,
|
||||
202,
|
||||
600,
|
||||
]]],
|
||||
["drag returned to start", [[120, 200, 100], [300, 202, 300], [
|
||||
125,
|
||||
203,
|
||||
900,
|
||||
]]],
|
||||
["reverse flick overrides distance", [[120, 200, 100], [320, 202, 200], [
|
||||
320,
|
||||
202,
|
||||
380,
|
||||
], [260, 203, 430]]],
|
||||
] as const
|
||||
) {
|
||||
test(`${name} leaves the sidebar in its original state`, async () => {
|
||||
const { node, open, send } = fixture();
|
||||
points.forEach(([x, y, time], i) =>
|
||||
send(i === 0 ? "touchstart" : "touchmove", x, y, time)
|
||||
);
|
||||
const [x, y, time] = points.at(-1)!;
|
||||
send("touchend", x, y, time + 10);
|
||||
await Promise.resolve();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
for (
|
||||
const html of [
|
||||
'<input type="range">',
|
||||
"<textarea></textarea>",
|
||||
"<select></select>",
|
||||
'<div contenteditable="true">Editor</div>',
|
||||
'<div role="slider">Slider</div>',
|
||||
"<dialog open>Dialog</dialog>",
|
||||
"<div data-sidebar-swipe-ignore>Custom gesture</div>",
|
||||
]
|
||||
) {
|
||||
test(`keeps native controls unclaimed: ${html}`, () => {
|
||||
const { open, send, listeners } = fixture(html);
|
||||
send("touchstart", 120, 200);
|
||||
expect(send("touchmove", 290, 201, 300).defaultPrevented).toBe(false);
|
||||
send("touchend", 290, 201, 310);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(listeners.mock.calls.some(([name]) => name === "touchmove")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("nested horizontal scrolling remains excluded even at its boundary", () => {
|
||||
const { target, open, send } = fixture();
|
||||
(target as HTMLElement).style.overflowX = "auto";
|
||||
Object.defineProperty(target, "scrollWidth", { value: 800 });
|
||||
Object.defineProperty(target, "clientWidth", { value: 300 });
|
||||
send("touchstart", 120, 200);
|
||||
expect(send("touchmove", 290, 201, 300).defaultPrevented).toBe(false);
|
||||
send("touchend", 290, 201, 310);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (
|
||||
const reason of [
|
||||
"scroll",
|
||||
"touchcancel",
|
||||
"multitouch",
|
||||
"disabled",
|
||||
"state-change",
|
||||
"navigation",
|
||||
"resize",
|
||||
"hidden",
|
||||
"identifier",
|
||||
"destroy",
|
||||
"selection",
|
||||
"native-claimed",
|
||||
]
|
||||
) {
|
||||
test(`cancels preview after ${reason}`, () => {
|
||||
const { node, target, open, send, disable, flipState, navigate } =
|
||||
fixture();
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 160, 202, 150);
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(true);
|
||||
if (reason === "scroll" || reason === "touchcancel") {
|
||||
target.dispatchEvent(
|
||||
new Event(reason, { bubbles: reason === "touchcancel" }),
|
||||
);
|
||||
}
|
||||
if (reason === "multitouch") send("touchstart", 160, 202, 160, 2);
|
||||
if (reason === "disabled") disable();
|
||||
if (reason === "state-change") flipState();
|
||||
if (reason === "navigation") navigate();
|
||||
if (reason === "resize") window.dispatchEvent(new Event("resize"));
|
||||
if (reason === "hidden") {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
if (reason === "destroy") destroy?.();
|
||||
if (reason === "selection") {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(target);
|
||||
document.getSelection()?.addRange(range);
|
||||
}
|
||||
if (reason === "native-claimed") {
|
||||
send("touchmove", 200, 203, 200, 1, 1, false);
|
||||
}
|
||||
send("touchend", 290, 203, 300, 1, reason === "identifier" ? 2 : 1);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const html of ['<a href="#link">Link</a>', "<button>Action</button>"]) {
|
||||
test(`swipe from ${html} suppresses only its click, not later taps or keyboard activation`, async () => {
|
||||
const { open, send, click, target } = fixture(html);
|
||||
const activate = vi.fn();
|
||||
target.addEventListener("click", activate);
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 156, 202, 140);
|
||||
send("touchend", 156, 202, 150);
|
||||
await Promise.resolve();
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
expect(click(156, 202).defaultPrevented).toBe(true);
|
||||
expect(activate).not.toHaveBeenCalled();
|
||||
expect(click(156, 202, 0).defaultPrevented).toBe(false);
|
||||
send("touchstart", 160, 200, 200);
|
||||
send("touchend", 160, 200, 250);
|
||||
expect(click(160, 200).defaultPrevented).toBe(false);
|
||||
expect(activate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
}
|
||||
|
||||
test("a claimed but incomplete swipe also suppresses accidental activation", async () => {
|
||||
const { open, send, click } = fixture("<button>Action</button>");
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 160, 202, 400);
|
||||
send("touchend", 160, 202, 500);
|
||||
await Promise.resolve();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(click(160, 202).defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
type Options = {
|
||||
enabled: boolean;
|
||||
open: boolean;
|
||||
route: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type Sample = { x: number; time: number };
|
||||
const SLOP = 10;
|
||||
const AXIS_RATIO = 1.5;
|
||||
const MIN_FLING_DISTANCE = 24;
|
||||
const FLING_VELOCITY = 450; // CSS px/s, measured over the last 120ms.
|
||||
const COMMIT_FRACTION = 0.4;
|
||||
|
||||
// Leave undecided/vertical gestures to the browser. Only a confirmed horizontal
|
||||
// drag claims touchmove; no ancestor touch-action restriction disables nested
|
||||
// horizontal scrolling or pinch zoom. Preview never changes admission/focus.
|
||||
export function swipeSidebar(node: HTMLElement, options: Options) {
|
||||
let gesture: {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
horizontal: boolean;
|
||||
wasOpen: boolean;
|
||||
width: number;
|
||||
startOffset: number;
|
||||
offset: number;
|
||||
peak: number;
|
||||
samples: Sample[];
|
||||
} | null = null;
|
||||
let suppressedClick: { x: number; y: number; until: number } | null = null;
|
||||
let cleanupVersion = 0;
|
||||
const view = node.ownerDocument.defaultView;
|
||||
const selectedText = () =>
|
||||
node.ownerDocument.getSelection()?.isCollapsed === false;
|
||||
const clearPreview = () => {
|
||||
node.classList.remove("sidebar-swiping");
|
||||
node.style.removeProperty("--sidebar-swipe-offset");
|
||||
};
|
||||
const cancel = () => {
|
||||
gesture = null;
|
||||
node.removeEventListener("touchmove", move);
|
||||
cleanupVersion++;
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
function excluded(target: EventTarget | null): boolean {
|
||||
let element = target instanceof Element ? target : null;
|
||||
if (
|
||||
!element || element.closest(
|
||||
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="spinbutton"], [role="scrollbar"], [role="dialog"], [role="menu"], [role="listbox"], dialog, video, audio, canvas, [draggable="true"], [data-sidebar-swipe-ignore]',
|
||||
)
|
||||
) return true;
|
||||
// A horizontal scroll surface owns the whole gesture, including its edges.
|
||||
for (; element; element = element.parentElement) {
|
||||
if (
|
||||
element.scrollWidth > element.clientWidth + 1 &&
|
||||
/^(auto|scroll|overlay)$/.test(getComputedStyle(element).overflowX)
|
||||
) return true;
|
||||
if (element === node) break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function start(event: TouchEvent) {
|
||||
cancel();
|
||||
suppressedClick = null; // A new tap must not inherit the previous swipe.
|
||||
if (
|
||||
!options.enabled || event.defaultPrevented ||
|
||||
event.touches.length !== 1 || selectedText() || excluded(event.target)
|
||||
) return;
|
||||
const touch = event.touches[0];
|
||||
const viewportWidth = view?.innerWidth ?? 0;
|
||||
if (touch.clientX < 24 || touch.clientX > viewportWidth - 24) return;
|
||||
const panel = node.querySelector<HTMLElement>(".sidebar-frame");
|
||||
if (!panel) return;
|
||||
const bounds = panel.getBoundingClientRect();
|
||||
if (bounds.width <= 0) return;
|
||||
const offset = Math.min(
|
||||
0,
|
||||
Math.max(-bounds.width, bounds.left - node.getBoundingClientRect().left),
|
||||
);
|
||||
gesture = {
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
horizontal: false,
|
||||
wasOpen: options.open,
|
||||
width: bounds.width,
|
||||
startOffset: offset,
|
||||
offset,
|
||||
peak: 0,
|
||||
samples: [{ x: 0, time: event.timeStamp }],
|
||||
};
|
||||
// Install a potentially blocking listener only for an eligible gesture.
|
||||
node.addEventListener("touchmove", move, { passive: false });
|
||||
}
|
||||
|
||||
function track(event: TouchEvent, touch: Touch): boolean {
|
||||
const current = gesture;
|
||||
if (!current) return false;
|
||||
if (
|
||||
!options.enabled || options.open !== current.wasOpen ||
|
||||
event.defaultPrevented || selectedText() ||
|
||||
touch.identifier !== current.id
|
||||
) {
|
||||
cancel();
|
||||
return false;
|
||||
}
|
||||
const rawDx = touch.clientX - current.x;
|
||||
const dx = rawDx * (current.wasOpen ? -1 : 1);
|
||||
const dy = Math.abs(touch.clientY - current.y);
|
||||
if (!current.horizontal) {
|
||||
if (
|
||||
(dy >= SLOP && dy > Math.abs(dx) * AXIS_RATIO) ||
|
||||
(dx <= -SLOP && -dx > dy * AXIS_RATIO)
|
||||
) {
|
||||
cancel();
|
||||
return false;
|
||||
}
|
||||
// An ambiguous diagonal is not a failed swipe. Keep observing until
|
||||
// intent becomes clear, unless the browser starts scrolling first.
|
||||
if (dx < SLOP || dx < dy * AXIS_RATIO) return false;
|
||||
if (!event.cancelable) {
|
||||
cancel(); // Native scrolling already owns this stream.
|
||||
return false;
|
||||
}
|
||||
current.horizontal = true;
|
||||
}
|
||||
if (!event.cancelable && event.type !== "touchend") {
|
||||
cancel();
|
||||
return false;
|
||||
}
|
||||
if (event.cancelable) event.preventDefault();
|
||||
current.offset = Math.min(
|
||||
0,
|
||||
Math.max(-current.width, current.startOffset + rawDx),
|
||||
);
|
||||
current.peak = Math.max(current.peak, dx);
|
||||
current.samples.push({ x: dx, time: event.timeStamp });
|
||||
current.samples = current.samples.filter((sample) =>
|
||||
sample.time >= event.timeStamp - 120
|
||||
);
|
||||
suppressedClick = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
until: performance.now() + 750,
|
||||
};
|
||||
node.style.setProperty("--sidebar-swipe-offset", `${current.offset}px`);
|
||||
node.classList.add("sidebar-swiping");
|
||||
return true;
|
||||
}
|
||||
|
||||
function move(event: TouchEvent) {
|
||||
if (event.touches.length !== 1) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
track(event, event.touches[0]);
|
||||
}
|
||||
|
||||
function end(event: TouchEvent) {
|
||||
// Do not turn an unclaimed touch into a swipe only after the finger lifts.
|
||||
if (
|
||||
!gesture?.horizontal || event.touches.length !== 0 ||
|
||||
event.changedTouches.length !== 1 ||
|
||||
!track(event, event.changedTouches[0])
|
||||
) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
const current = gesture;
|
||||
const last = current.samples.at(-1)!;
|
||||
const first = current.samples[0];
|
||||
const duration = last.time - first.time;
|
||||
const velocity = duration >= 8 ? (last.x - first.x) * 1000 / duration : 0;
|
||||
const progress = current.wasOpen
|
||||
? -current.offset
|
||||
: current.width + current.offset;
|
||||
const reversed = velocity <= -FLING_VELOCITY &&
|
||||
current.peak - last.x >= MIN_FLING_DISTANCE;
|
||||
const commit = !reversed && (
|
||||
progress >= current.width * COMMIT_FRACTION ||
|
||||
(last.x >= MIN_FLING_DISTANCE && velocity >= FLING_VELOCITY)
|
||||
);
|
||||
gesture = null;
|
||||
node.removeEventListener("touchmove", move);
|
||||
if (commit) options.setOpen(!current.wasOpen);
|
||||
// Let Svelte apply the final open/closed class before removing the preview;
|
||||
// CSS then settles from the actual dragged position, including snap-back.
|
||||
const version = ++cleanupVersion;
|
||||
queueMicrotask(() => {
|
||||
if (version === cleanupVersion) clearPreview();
|
||||
});
|
||||
}
|
||||
|
||||
function click(event: MouseEvent) {
|
||||
if (!suppressedClick || performance.now() > suppressedClick.until) return;
|
||||
// Keep keyboard activation and a subsequent deliberate tap intact.
|
||||
if (event.detail === 0) return;
|
||||
if (
|
||||
Math.hypot(
|
||||
event.clientX - suppressedClick.x,
|
||||
event.clientY - suppressedClick.y,
|
||||
) > 32
|
||||
) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
suppressedClick = null;
|
||||
}
|
||||
|
||||
const passive = { passive: true };
|
||||
node.addEventListener("touchstart", start, passive);
|
||||
node.addEventListener("touchend", end, { passive: false });
|
||||
node.addEventListener("touchcancel", cancel, passive);
|
||||
node.addEventListener("scroll", cancel, { passive: true, capture: true });
|
||||
node.addEventListener("click", click, true);
|
||||
view?.addEventListener("resize", cancel);
|
||||
node.ownerDocument.addEventListener("visibilitychange", cancel);
|
||||
return {
|
||||
update(next: Options) {
|
||||
if (
|
||||
!next.enabled || next.open !== options.open ||
|
||||
next.route !== options.route
|
||||
) cancel();
|
||||
options = next;
|
||||
},
|
||||
destroy() {
|
||||
cancel();
|
||||
node.removeEventListener("touchstart", start);
|
||||
node.removeEventListener("touchend", end);
|
||||
node.removeEventListener("touchcancel", cancel);
|
||||
node.removeEventListener("scroll", cancel, true);
|
||||
node.removeEventListener("click", click, true);
|
||||
view?.removeEventListener("resize", cancel);
|
||||
node.ownerDocument.removeEventListener("visibilitychange", cancel);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
// MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。
|
||||
import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { setContext } from 'svelte';
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import { browserSession, type SessionSnapshot } from '#lib/workspace/auth/session-state.ts';
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
import WorkspaceAlerts from '#lib/workspace/alerts/WorkspaceAlerts.svelte';
|
||||
import { dismissWorkspaceAlert, pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
|
||||
@@ -20,6 +21,7 @@
|
||||
import SidebarFrame from '#lib/workspace/sidebar/SidebarFrame.svelte';
|
||||
import SidebarToggleIcon from '#lib/workspace/sidebar/SidebarToggleIcon.svelte';
|
||||
import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '#lib/workspace/sidebar/context.ts';
|
||||
import { swipeSidebar } from '#lib/workspace/sidebar/swipe-sidebar.ts';
|
||||
import { createOverrideStack } from '#lib/workspace/sidebar/override-stack.ts';
|
||||
import '../app.css';
|
||||
import type { LayoutProps } from './$types';
|
||||
@@ -28,8 +30,11 @@
|
||||
|
||||
let { children, data }: LayoutProps = $props();
|
||||
import { isAuthPage } from '#lib/workspace/auth/routes.ts';
|
||||
// 公開入口・認証・Workspace選択はWorkspace catalogやSidebarに依存しない。
|
||||
const standalone = $derived(page.url.pathname === '/' || page.url.pathname === '/dashboard' || isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/'));
|
||||
let session = $state<SessionSnapshot>(browserSession.snapshot);
|
||||
onMount(() => browserSession.subscribe((snapshot) => { session = snapshot; }));
|
||||
const authenticated = $derived(session.kind === 'ready' && session.value.session.kind === 'authenticated');
|
||||
// 公開トップのSidebarはログイン確認済みの場合だけ表示する。
|
||||
const standalone = $derived((page.url.pathname === '/' && !authenticated) || page.url.pathname === '/dashboard' || isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/'));
|
||||
const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog');
|
||||
let sidebar = $state<SidebarSnippet | null>(null);
|
||||
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
|
||||
@@ -103,8 +108,19 @@
|
||||
{@render children()}
|
||||
</main>
|
||||
{:else}
|
||||
<WorkspaceAlerts />
|
||||
<div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
|
||||
<!-- Workspace通知は公開トップや本人設定へ持ち越さない。 -->
|
||||
{#if page.url.pathname !== '/' && page.url.pathname !== '/account'}
|
||||
<WorkspaceAlerts />
|
||||
{/if}
|
||||
<div
|
||||
use:swipeSidebar={{
|
||||
enabled: mobileLayout.current && authenticated,
|
||||
open: sidebarOpen,
|
||||
route: page.url.href,
|
||||
setOpen: (open) => { sidebarTransientOpen = open; },
|
||||
}}
|
||||
class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}
|
||||
>
|
||||
<SidebarFrame
|
||||
mode={sidebarMode}
|
||||
open={sidebarOpen}
|
||||
@@ -115,7 +131,7 @@
|
||||
<GlobalSidebar
|
||||
currentPath={page.url.pathname}
|
||||
content={sidebar}
|
||||
workspaces={catalog.data?.accessibleWorkspaces ?? null}
|
||||
workspaces={page.url.pathname === '/' ? null : catalog.data?.accessibleWorkspaces ?? null}
|
||||
workspaceError={catalog.data?.workspaceCatalogError}
|
||||
/>
|
||||
</SidebarFrame>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
|
||||
import { AUTHENTICATED_HOME } from '#lib/workspace/auth/routes.ts';
|
||||
import RegistrationLink from '#lib/workspace/auth/RegistrationLink.svelte';
|
||||
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
|
||||
import '#lib/workspace/auth/auth-page.css';
|
||||
|
||||
@@ -31,8 +30,6 @@
|
||||
{#key auth.actor.account_id}<AccountNavigation />{/key}
|
||||
{:else}
|
||||
<a class="entry-primary" href="/login">Log in</a>
|
||||
<RegistrationLink />
|
||||
<a href="/activate">Activate an account with a code</a>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { addPasskey, logout } from '#lib/workspace/auth/api.ts';
|
||||
import type { PasskeyListResponse } from '#lib/generated/auth-api.ts';
|
||||
import { addPasskey, logout, loadPasskeys, loadWhoami } from '#lib/workspace/auth/api.ts';
|
||||
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
|
||||
import { browserSession } from '#lib/workspace/auth/session-state.ts';
|
||||
import { loginUrl } from '#lib/workspace/auth/routes.ts';
|
||||
import '#lib/workspace/auth/auth-page.css';
|
||||
|
||||
const auth = new BrowserAuth();
|
||||
async function refresh() {
|
||||
await auth.refresh();
|
||||
let keys = $state<PasskeyListResponse | null>(null);
|
||||
let keysError = $state('');
|
||||
let keysLoading = $state(false);
|
||||
let keyRead = 0;
|
||||
let active = true;
|
||||
let keyController: AbortController | null = null;
|
||||
function time(value: string | null) { return value ? new Date(value).toLocaleString() : 'Not recorded'; }
|
||||
async function refreshKeys() {
|
||||
const actor = auth.actor;
|
||||
if (!actor || !active) return;
|
||||
keyController?.abort();
|
||||
const controller = new AbortController();
|
||||
keyController = controller;
|
||||
const read = ++keyRead;
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
const assertCurrent = () => {
|
||||
controller.signal.throwIfAborted();
|
||||
if (!active || read !== keyRead || generation !== browserSession.authenticationGeneration || stamp !== browserSession.authenticationStamp) throw new Error('Session changed');
|
||||
};
|
||||
keysLoading = true; keysError = '';
|
||||
try {
|
||||
const before = await loadWhoami(controller.signal);
|
||||
assertCurrent();
|
||||
if (before.actor?.user_id !== actor.user_id || before.actor.account_id !== actor.account_id) throw new Error('Account changed');
|
||||
const result = await loadPasskeys(controller.signal);
|
||||
assertCurrent();
|
||||
const after = await loadWhoami(controller.signal);
|
||||
assertCurrent();
|
||||
if (result.user.user_id !== actor.user_id || result.user.account_id !== actor.account_id ||
|
||||
after.actor?.user_id !== actor.user_id || after.actor.account_id !== actor.account_id ||
|
||||
before.authentication_reference !== after.authentication_reference) throw new Error('Session changed');
|
||||
keys = result;
|
||||
} catch {
|
||||
if (active && read === keyRead && !controller.signal.aborted) { keys = null; keysError = 'Registered keys could not be confirmed for this account. Refresh your session before trying again.'; }
|
||||
} finally { if (read === keyRead) keysLoading = false; }
|
||||
}
|
||||
async function refresh() { await auth.refresh(); await refreshKeys(); }
|
||||
async function add() {
|
||||
if (!auth.actor) return;
|
||||
await auth.run('Waiting for passkey addition…', 'Passkey added to your account.', addPasskey);
|
||||
if (!auth.actor || auth.additionAttempt && !auth.additionSaved) return;
|
||||
await auth.run('Waiting for passkey addition…', 'Passkey added to your account; the expected session was confirmed.', addPasskey);
|
||||
await refreshKeys();
|
||||
}
|
||||
async function checkAddition() { await auth.checkAddition(); await refreshKeys(); }
|
||||
async function signOut() {
|
||||
if (!auth.actor) return;
|
||||
const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout');
|
||||
if (confirmed && !auth.actor) await goto('/', { replaceState: true });
|
||||
}
|
||||
onMount(() => { void auth.ensure(); return () => auth.dispose(); });
|
||||
onMount(() => { void auth.ensure().then(refreshKeys); return () => { active = false; ++keyRead; keyController?.abort(); auth.dispose(); }; });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Account · Yoi</title></svelte:head>
|
||||
<div class="auth-page" data-sveltekit-preload-data="off">
|
||||
<h1>Account</h1>
|
||||
{#if auth.status}<p role="status">{auth.status}</p>{/if}
|
||||
{#if auth.busy}<button type="button" onclick={() => auth.cancel()}>Stop waiting</button>{/if}
|
||||
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
|
||||
{#if auth.session.kind === 'loading'}
|
||||
<p class="muted" role="status">Loading session…</p>
|
||||
{:else if auth.session.kind === 'error'}
|
||||
@@ -37,8 +79,41 @@
|
||||
</dl>
|
||||
<section aria-label="Your Passkeys">
|
||||
<h2>Passkeys</h2>
|
||||
<p class="muted">Add a discoverable passkey for login without an account ID. Use a device or key that can save passkeys and verify you with a PIN or biometrics. Existing keys are preserved; do not remove them before confirming the new key works.</p>
|
||||
<button type="button" disabled={auth.busy} onclick={add}>Add passkey to {auth.actor.handle}</button>
|
||||
<p class="muted">Add a passkey for login without an account ID. Use a device or key that saves passkeys and verifies you with a PIN or biometrics. Existing keys are preserved.</p>
|
||||
<p>If an older key is in the same authenticator or saved-key provider, duplicate exclusion can block addition there. Choose a different device, security key, or passkey provider. A different provider on the same device may work only if it does not contain an excluded key. Do not delete an older key to bypass this check.</p>
|
||||
<button type="button" disabled={auth.busy || !!auth.additionAttempt && !auth.additionSaved} onclick={add}>Add passkey to {auth.actor.handle}</button>
|
||||
{#if auth.additionAttempt}
|
||||
<button type="button" disabled={auth.busy} onclick={checkAddition}>Check addition result</button>
|
||||
<p class="muted">A saved result confirms this addition's server record, not that ID-free login works. If the session changed, result access may be unavailable; do not restore an old session or add blindly.</p>
|
||||
{/if}
|
||||
{#if keysLoading}<p role="status">Checking registered keys…</p>{/if}
|
||||
{#if keysError}<p role="alert" class="error">{keysError}</p>{/if}
|
||||
{#if keys && keys.user.user_id === auth.actor.user_id && keys.user.account_id === auth.actor.account_id}
|
||||
<ul class="passkey-list" aria-label="Registered keys">
|
||||
{#each keys.keys as key (key.key_reference)}
|
||||
<li>
|
||||
<dl>
|
||||
<div><dt>Created</dt><dd>{time(key.created_at)}</dd></div>
|
||||
<div><dt>Last used</dt><dd>{time(key.last_used_at)}</dd></div>
|
||||
<div><dt>ID-free login</dt><dd>{key.normal_login_verified_at ? `Verified ${time(key.normal_login_verified_at)}` : 'Not yet verified — storage alone does not prove support'}</dd></div>
|
||||
</dl>
|
||||
<details><summary>Key reference</summary><code>{key.key_reference}</code></details>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if keys.keys.length === 0}<p>No registered keys were returned for this account.</p>{/if}
|
||||
{/if}
|
||||
<button type="button" disabled={auth.busy || keysLoading} onclick={refreshKeys}>Refresh registered keys</button>
|
||||
<details>
|
||||
<summary>Verify migration before retiring older-key login</summary>
|
||||
<ol>
|
||||
<li>Keep this working session and every working older key. If addition is excluded, use a different saved-key provider or authenticator; removing your last key first can lock you out.</li>
|
||||
<li>In a separate browser profile or private window, open <a href="/login" target="_blank" rel="noopener">normal passkey login</a>. Do not open the older-key/ID form. Select the newly saved key and complete verification.</li>
|
||||
<li>Confirm the intended account ID in that window's Account page. Refresh the key list here and check that the chosen key now records verified ID-free login. Repeat for every key and device you intend to rely on; a stored key or resident-key hint alone is not proof.</li>
|
||||
<li>Retain a tested independent way back in before ceasing to rely on an older key. If any required device cannot complete normal login, keep the older-key path available. Do not delete old keys as a prerequisite.</li>
|
||||
</ol>
|
||||
<p>Removing the older-key entry for the service is not safe based on one account's success. The operator must first confirm migration and a working alternative for every affected account/device, keep a rollback path to the older-key entry, and verify normal login before retiring that entry. This page does not delete keys or disable compatibility login.</p>
|
||||
</details>
|
||||
</section>
|
||||
<div class="actions">
|
||||
<button type="button" disabled={auth.busy} onclick={signOut}>Log out</button>
|
||||
@@ -48,7 +123,12 @@
|
||||
<p>No browser session is active.</p>
|
||||
<a href={loginUrl('/account')}>Log in to your account</a>
|
||||
{/if}
|
||||
{#if auth.status}<p role="status">{auth.status}</p>{/if}
|
||||
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
|
||||
<a href="/dashboard">Dashboard / Workspaces</a>
|
||||
</div>
|
||||
<style>
|
||||
.passkey-list { padding-inline-start: var(--space-5); display: grid; gap: var(--space-4); }
|
||||
.passkey-list li { min-width: 0; }
|
||||
code { overflow-wrap: anywhere; }
|
||||
ol { padding-inline-start: var(--space-5); }
|
||||
li + li { margin-block-start: var(--space-2); }
|
||||
</style>
|
||||
|
||||
@@ -63,6 +63,9 @@ function boundary({
|
||||
return new Response(null, { status: state.whoamiStatus });
|
||||
}
|
||||
return Response.json({
|
||||
authentication_reference: state.authenticated
|
||||
? "fixture-authentication"
|
||||
: null,
|
||||
session: state.authenticated
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -71,6 +74,9 @@ function boundary({
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys") {
|
||||
return Response.json({ user: authUser, keys: [] });
|
||||
}
|
||||
if (path === "/api/auth/registration-policy") {
|
||||
if (state.policy === "loading") return await policyResponse.promise;
|
||||
if (state.policy === "error") return new Response(null, { status: 503 });
|
||||
@@ -97,7 +103,10 @@ function boundary({
|
||||
return new Response(null, { status: state.completeStatus });
|
||||
}
|
||||
state.authenticated = true;
|
||||
return Response.json({ user: authUser });
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/logout") {
|
||||
state.authenticated = false;
|
||||
@@ -149,6 +158,143 @@ async function submit(name: string) {
|
||||
await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!);
|
||||
}
|
||||
|
||||
for (const phase of ["initial-whoami", "list", "final-whoami"] as const) {
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
test(`registered-key ${phase} confirmation rejects shared ${reason} before event delivery`, async () => {
|
||||
const { fetcher } = boundary({ authenticated: true });
|
||||
render(Account);
|
||||
await screen.findByText(
|
||||
"No registered keys were returned for this account.",
|
||||
);
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
const started = gate<void>(), response = gate<Response>();
|
||||
let whoami = 0, listReads = 0;
|
||||
const metadata = () =>
|
||||
Response.json({
|
||||
user: authUser,
|
||||
keys: [{
|
||||
key_reference: "delayed-key-metadata",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
normal_login_verified_at: null,
|
||||
}],
|
||||
});
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
++whoami;
|
||||
if (phase === (whoami === 1 ? "initial-whoami" : "final-whoami")) {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
}
|
||||
if (path === "/api/auth/passkeys") {
|
||||
++listReads;
|
||||
if (phase === "list") {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return metadata();
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Refresh registered keys" }),
|
||||
);
|
||||
await started.promise;
|
||||
const changed = JSON.stringify({ id: `keys-${phase}-${reason}`, reason });
|
||||
localStorage.setItem("yoi.browser-auth-generation", changed);
|
||||
expect(browserSession.authenticationStamp).not.toBe(stamp);
|
||||
expect(browserSession.authenticationGeneration).toBe(generation);
|
||||
response.resolve(
|
||||
phase === "list" ? metadata() : await request("/api/auth/whoami"),
|
||||
);
|
||||
await screen.findByText(/Registered keys could not be confirmed/);
|
||||
expect(screen.queryByText("delayed-key-metadata")).toBeNull();
|
||||
expect(listReads).toBe(phase === "initial-whoami" ? 0 : 1);
|
||||
expect(whoami).toBe(phase === "final-whoami" ? 2 : 1);
|
||||
expect(localStorage.getItem("yoi.browser-auth-generation")).toBe(changed);
|
||||
expect(posted(fetcher)).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
test(`stale receipt confirmation after shared ${reason} leaves visible repeat-add disabled`, async () => {
|
||||
const { fetcher } = boundary({ authenticated: true });
|
||||
render(Account);
|
||||
await screen.findByText(
|
||||
"No registered keys were returned for this account.",
|
||||
);
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
const started = gate<void>(), response = gate<Response>();
|
||||
let receiptRead = false;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path.endsWith("/addition/complete")) {
|
||||
throw new TypeError("lost response");
|
||||
}
|
||||
if (path.endsWith("/addition/result")) {
|
||||
receiptRead = true;
|
||||
expect(JSON.parse(String(init?.body)).expected_authentication_reference)
|
||||
.toBe("fixture-authentication");
|
||||
return Response.json({
|
||||
status: "saved",
|
||||
user: authUser,
|
||||
key: {
|
||||
key_reference: "saved-but-session-unconfirmed",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
normal_login_verified_at: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami" && receiptRead) {
|
||||
receiptRead = false;
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
const add = screen.getByRole("button", {
|
||||
name: /Add passkey to/,
|
||||
}) as HTMLButtonElement;
|
||||
await fireEvent.click(add);
|
||||
await screen.findByText(/result is unknown/);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Checking registered keys…")).toBeNull()
|
||||
);
|
||||
expect(add.disabled).toBe(true);
|
||||
const checking = screen.getByRole("button", {
|
||||
name: "Check addition result",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect((checking as HTMLButtonElement).disabled).toBe(false)
|
||||
);
|
||||
await fireEvent.click(checking);
|
||||
await started.promise;
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
localStorage.setItem(
|
||||
"yoi.browser-auth-generation",
|
||||
JSON.stringify({ id: `visible-${reason}`, reason }),
|
||||
);
|
||||
expect(browserSession.authenticationGeneration).toBe(generation);
|
||||
response.resolve(await request("/api/auth/whoami"));
|
||||
await screen.findByText(/The addition result could not be read/);
|
||||
await waitFor(() =>
|
||||
expect((checking as HTMLButtonElement).disabled).toBe(false)
|
||||
);
|
||||
expect(screen.queryByText(/Your current session was checked/)).toBeNull();
|
||||
expect(add.disabled).toBe(true);
|
||||
await fireEvent.click(add);
|
||||
expect(
|
||||
posted(fetcher).filter(({ path }) => path.endsWith("/addition/complete")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test("normal login needs no account ID and links separate registration and code activation", async () => {
|
||||
const { fetcher } = boundary({ policy: true });
|
||||
page.url = new URL("https://example.test/login?returnTo=%2Faccount");
|
||||
@@ -231,19 +377,23 @@ for (
|
||||
expect(get).toHaveBeenCalledOnce();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(2);
|
||||
.toHaveLength(4);
|
||||
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
|
||||
});
|
||||
}
|
||||
|
||||
test("successful login completion without confirmed whoami never navigates", async () => {
|
||||
const { state, get } = boundary();
|
||||
const { state, fetcher } = boundary();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const response = await request(input, init);
|
||||
if (String(input) === "/api/auth/passkeys/login/complete") {
|
||||
state.whoamiStatus = 503;
|
||||
}
|
||||
return response;
|
||||
});
|
||||
render(Login);
|
||||
await enterIdentity();
|
||||
get.mockImplementationOnce(async () => {
|
||||
state.whoamiStatus = 503;
|
||||
return credentialFixture(true);
|
||||
});
|
||||
await submit("Log in with passkey");
|
||||
await screen.findByText(/Your session could not be loaded/);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
@@ -347,7 +497,7 @@ test("policy disabled during WebAuthn rejects registration, reloads policy and n
|
||||
await waitFor(() => expect(create).toHaveBeenCalledOnce());
|
||||
state.completeStatus = 403;
|
||||
credential.resolve(credentialFixture());
|
||||
await screen.findByText("Auth request failed (403).");
|
||||
await screen.findByText(/\[complete\/rejected\/HTTP-403\]/);
|
||||
await screen.findByText(/New account registration is disabled/);
|
||||
expect(screen.queryByRole("button", { name: "Register new account" }))
|
||||
.toBeNull();
|
||||
@@ -376,7 +526,9 @@ test("account displays the current identity without editable target fields and a
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add passkey to alice" }),
|
||||
);
|
||||
await screen.findByText("Passkey added to your account.");
|
||||
await screen.findByText(
|
||||
"Passkey added to your account; the expected session was confirmed.",
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement)
|
||||
@@ -388,7 +540,12 @@ test("account displays the current identity without editable target fields and a
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
expect(requests[0].body).toEqual({ browser_origin: window.location.origin });
|
||||
expect(requests[0].body).toEqual({
|
||||
browser_origin: window.location.origin,
|
||||
expected_user_id: authUser.user_id,
|
||||
expected_account_id: authUser.account_id,
|
||||
expected_authentication_reference: "fixture-authentication",
|
||||
});
|
||||
expect(Object.keys(requests[1].body).sort()).toEqual([
|
||||
"challenge_id",
|
||||
"credential",
|
||||
@@ -485,11 +642,18 @@ test("logout confirms the server session is cleared and returns to the public ho
|
||||
expect(posted(fetcher)).toEqual([
|
||||
{ path: "/api/auth/logout", body: {}, credentials: "same-origin" },
|
||||
]);
|
||||
expect(apiPaths(fetcher)).toEqual([
|
||||
const paths = apiPaths(fetcher);
|
||||
expect(paths[0]).toBe("/api/auth/whoami");
|
||||
expect(paths.slice(paths.indexOf("/api/auth/logout") + 1)).toContain(
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
);
|
||||
expect(
|
||||
paths.every((path) =>
|
||||
["/api/auth/whoami", "/api/auth/logout", "/api/auth/passkeys"].includes(
|
||||
path,
|
||||
)
|
||||
),
|
||||
).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -499,10 +663,14 @@ test("expired addition revokes stale identity without silently retrying as regis
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add passkey to alice" }),
|
||||
);
|
||||
await screen.findByText(/Authentication request was rejected/);
|
||||
await screen.findByText(/server rejected the request or verification/);
|
||||
await screen.findByRole("link", { name: "Log in to your account" });
|
||||
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
|
||||
expect(screen.queryByText("Passkey added to your account.")).toBeNull();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Passkey added to your account; the expected session was confirmed.",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
@@ -545,7 +713,13 @@ test("WebAuthn cancellation is visible, sends no completion, and allows an expli
|
||||
await enterIdentity();
|
||||
await submit("Log in with passkey");
|
||||
await screen.findByText(
|
||||
"Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again.",
|
||||
/cancelled, timed out, or found no usable key/,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"Completion was not sent.",
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"[authenticator/cancelled-or-unavailable]",
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -651,7 +825,12 @@ test("device approval locks its code and rejects a second submit while the serve
|
||||
name: "Approve device login",
|
||||
});
|
||||
const response = gate<Response>();
|
||||
fetcher.mockReturnValueOnce(response.promise);
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) =>
|
||||
String(input) === "/api/auth/device-login/approve"
|
||||
? response.promise
|
||||
: request(input, init)
|
||||
);
|
||||
const form = approve.closest("form")!;
|
||||
await fireEvent.submit(form);
|
||||
await screen.findByText("Approving device login…");
|
||||
@@ -659,9 +838,11 @@ test("device approval locks its code and rejects a second submit while the serve
|
||||
expect((screen.getByLabelText("User code") as HTMLInputElement).disabled)
|
||||
.toBe(true);
|
||||
await fireEvent.submit(form);
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/device-login/approve",
|
||||
]);
|
||||
await waitFor(() =>
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/device-login/approve",
|
||||
])
|
||||
);
|
||||
response.resolve(Response.json({ status: "approved", user: authUser }));
|
||||
await screen.findByText("Device login approved. You can return to the CLI.");
|
||||
expect(posted(fetcher)).toHaveLength(1);
|
||||
@@ -679,11 +860,8 @@ for (const status of [401, 410]) {
|
||||
(await screen.findByRole("button", { name: "Approve device login" }))
|
||||
.closest("form")!,
|
||||
);
|
||||
await screen.findByText(
|
||||
status === 401
|
||||
? /Authentication request was rejected/
|
||||
: /The request or authentication challenge is invalid or expired/,
|
||||
);
|
||||
await screen.findByText(/Authentication request was rejected/);
|
||||
expect(screen.getByRole("alert").textContent).toContain("(" + status + ")");
|
||||
expect(
|
||||
screen.queryByText("Device login approved. You can return to the CLI."),
|
||||
).toBeNull();
|
||||
@@ -721,7 +899,7 @@ test("compatibility login keeps its selected form and ID after cancellation and
|
||||
target: { value: "alice" },
|
||||
});
|
||||
await submit("Log in with older key");
|
||||
await screen.findByText(/cancelled or timed out/);
|
||||
await screen.findByText(/cancelled, timed out, or found no usable key/);
|
||||
await screen.findByText("Use an older key with account ID");
|
||||
expect(
|
||||
(screen.getByText("Use an older key with account ID").closest(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { activateWithPasskey } from '#lib/workspace/auth/activation.ts';
|
||||
import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
|
||||
import { notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||
import { browserSession } from '#lib/workspace/auth/session-state.ts';
|
||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||
|
||||
let handle = $state('');
|
||||
@@ -19,12 +20,15 @@
|
||||
try { handle = normalizeActivationHandle(handle); }
|
||||
catch { error = 'Check the account ID supplied by your administrator.'; return; }
|
||||
const current = ++generation;
|
||||
const authGeneration = browserSession.authenticationGeneration;
|
||||
const authStamp = browserSession.authenticationStamp;
|
||||
controller = new AbortController(); busy = true; error = '';
|
||||
const pending = activateWithPasskey(handle, code, controller.signal);
|
||||
code = '';
|
||||
try {
|
||||
const confirmed = await pending;
|
||||
if (current !== generation) return;
|
||||
if (authGeneration !== browserSession.authenticationGeneration || authStamp !== browserSession.authenticationStamp) throw new Error('Activation superseded');
|
||||
user = confirmed;
|
||||
notifySessionChanged();
|
||||
} catch {
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import {
|
||||
browserSession,
|
||||
sessionMutation,
|
||||
} from "#lib/workspace/auth/session-state.ts";
|
||||
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
|
||||
import Activate from "./+page.svelte";
|
||||
import {
|
||||
authUser,
|
||||
@@ -15,9 +20,34 @@ import {
|
||||
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
|
||||
function gate<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function confirmedSession() {
|
||||
return {
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
};
|
||||
}
|
||||
function boundary(
|
||||
mode:
|
||||
| "success"
|
||||
| "pending"
|
||||
| "cancel"
|
||||
| "unknown"
|
||||
| "anonymous"
|
||||
| "bob"
|
||||
| "reference-mismatch"
|
||||
| "unavailable",
|
||||
) {
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
@@ -34,7 +64,36 @@ function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
|
||||
}
|
||||
if (String(input) === "/api/auth/activation/complete") {
|
||||
if (mode === "unknown") throw new Error("private diagnostic");
|
||||
return Response.json({ user: authUser });
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
if (mode === "unavailable") {
|
||||
return new Response("private diagnostic", { status: 503 });
|
||||
}
|
||||
if (mode === "anonymous") {
|
||||
return Response.json({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
}
|
||||
const confirmation = confirmedSession();
|
||||
if (mode === "bob") {
|
||||
confirmation.actor = {
|
||||
...confirmation.actor,
|
||||
user_id: "user-bob",
|
||||
account_id: "account-bob",
|
||||
handle: "bob",
|
||||
display_name: "Bob",
|
||||
};
|
||||
}
|
||||
if (mode === "reference-mismatch") {
|
||||
confirmation.authentication_reference = "another-authentication";
|
||||
}
|
||||
return Response.json(confirmation);
|
||||
}
|
||||
throw new Error("Unexpected endpoint");
|
||||
});
|
||||
@@ -54,12 +113,29 @@ async function submit() {
|
||||
}
|
||||
test("activation page shows signed in only after complete confirmation and clears code before browser prompt", async () => {
|
||||
const { fetcher } = boundary("success");
|
||||
const confirmation = gate<Response>();
|
||||
const started = gate<void>();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
started.resolve();
|
||||
return confirmation.promise;
|
||||
}
|
||||
return original(input, init);
|
||||
});
|
||||
render(Activate);
|
||||
await submit();
|
||||
await started.promise;
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
|
||||
expect((screen.getByLabelText("Activation code") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
confirmation.resolve(Response.json(confirmedSession()));
|
||||
await screen.findByText(/registration and activation confirmed/);
|
||||
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
expect(screen.queryByLabelText("Activation code")).toBeNull();
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe(
|
||||
@@ -99,3 +175,106 @@ for (const mode of ["cancel", "unknown"] as const) {
|
||||
.toBe("");
|
||||
});
|
||||
}
|
||||
|
||||
for (
|
||||
const mode of [
|
||||
"anonymous",
|
||||
"bob",
|
||||
"reference-mismatch",
|
||||
"unavailable",
|
||||
] as const
|
||||
) {
|
||||
test(
|
||||
"activation " + mode +
|
||||
" confirmation never claims signed in after successful completion",
|
||||
async () => {
|
||||
const { fetcher, create } = boundary(mode);
|
||||
const changed = vi.fn();
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, changed);
|
||||
try {
|
||||
render(Activate);
|
||||
await submit();
|
||||
await screen.findByRole("alert");
|
||||
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
expect(create).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"Passkey login",
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).not.toContain(
|
||||
"private diagnostic",
|
||||
);
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
|
||||
expect(
|
||||
(screen.getByLabelText("Activation code") as HTMLInputElement).value,
|
||||
).toBe("");
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, changed);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("logout during the activation prompt fences a late credential before completion", async () => {
|
||||
const { fetcher, create } = boundary("success");
|
||||
const credential = gate<Credential>();
|
||||
create.mockReturnValueOnce(credential.promise);
|
||||
render(Activate);
|
||||
await submit();
|
||||
await waitFor(() => expect(create).toHaveBeenCalledOnce());
|
||||
sessionMutation("logout");
|
||||
credential.resolve(credentialFixture());
|
||||
await screen.findByRole("alert");
|
||||
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
|
||||
"/api/auth/activation/options",
|
||||
]);
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
|
||||
});
|
||||
|
||||
for (const phase of ["prompt", "confirmation"] as const) {
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
test(`activation UI rejects shared ${reason} stamp during ${phase} before event delivery`, async () => {
|
||||
const { create, fetcher } = boundary("success");
|
||||
const started = gate<void>();
|
||||
const pending = gate<void>();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
if (phase === "prompt") {
|
||||
create.mockImplementation(async () => {
|
||||
started.resolve();
|
||||
await pending.promise;
|
||||
return credentialFixture();
|
||||
});
|
||||
} else {fetcher.mockImplementation(async (input, init) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
started.resolve();
|
||||
await pending.promise;
|
||||
}
|
||||
return await request(input, init);
|
||||
});}
|
||||
render(Activate);
|
||||
await submit();
|
||||
await started.promise;
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
localStorage.setItem(
|
||||
"yoi.browser-auth-generation",
|
||||
JSON.stringify({ id: crypto.randomUUID(), reason }),
|
||||
);
|
||||
expect(browserSession.authenticationGeneration).toBe(generation);
|
||||
pending.resolve();
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
expect(
|
||||
fetcher.mock.calls.filter(([path]) =>
|
||||
path === "/api/auth/activation/complete"
|
||||
),
|
||||
).toHaveLength(phase === "prompt" ? 0 : 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ function boundary() {
|
||||
if (state.sessionGate) return state.sessionGate.promise;
|
||||
return state.whoamiStatus === 200
|
||||
? Response.json({
|
||||
authentication_reference: state.signedIn ? "fixture-authentication" : null,
|
||||
session: state.signedIn
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -118,8 +119,8 @@ function boundary() {
|
||||
};
|
||||
}
|
||||
for (const policy of [true, false, "error", "loading"] as const) {
|
||||
test(`public home keeps login and exposes registration only for confirmed allowed policy: ${policy}`, async () => {
|
||||
const { state, workspaceCalls } = boundary();
|
||||
test(`public home routes account actions through login without loading registration policy: ${policy}`, async () => {
|
||||
const { state, fetcher, workspaceCalls } = boundary();
|
||||
state.policy = policy;
|
||||
state.catalogStatus = 401;
|
||||
render(Home);
|
||||
@@ -128,22 +129,15 @@ for (const policy of [true, false, "error", "loading"] as const) {
|
||||
"href",
|
||||
),
|
||||
).toBe("/login");
|
||||
if (policy === true) {
|
||||
expect(
|
||||
(await screen.findByRole("link", { name: "Create a new account" }))
|
||||
.getAttribute("href"),
|
||||
).toBe("/register?returnTo=%2Fdashboard");
|
||||
} else {
|
||||
await screen.findByText(
|
||||
policy === false
|
||||
? /registration is disabled/
|
||||
: policy === "error"
|
||||
? /availability is unknown/
|
||||
: /Checking registration/,
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: "Create a new account" }))
|
||||
.toBeNull();
|
||||
}
|
||||
expect(screen.queryByRole("link", { name: "Create a new account" }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Activate an account with a code" }),
|
||||
)
|
||||
.toBeNull();
|
||||
expect(screen.queryByText(/registration/i)).toBeNull();
|
||||
expect(fetcher.mock.calls.map(([path]) => String(path)))
|
||||
.not.toContain("/api/auth/registration-policy");
|
||||
expect(workspaceCalls()).toEqual([]);
|
||||
expect(screen.queryByText(/No accessible Workspaces/)).toBeNull();
|
||||
});
|
||||
@@ -333,6 +327,7 @@ test("pending create remains single during an explicit authentication change and
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
state.sessionGate.resolve(
|
||||
Response.json({
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
}),
|
||||
|
||||
@@ -22,6 +22,7 @@ function boundary(
|
||||
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
|
||||
if (input === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
|
||||
session: mode !== "anonymous"
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
|
||||
@@ -27,6 +27,7 @@ function boundary(
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: persona !== "anonymous" ? "fixture-authentication" : null,
|
||||
session: persona !== "anonymous"
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
|
||||
import { loginWithPasskey } from '#lib/workspace/auth/api.ts';
|
||||
import { loginWithPasskey, loadWhoami } from '#lib/workspace/auth/api.ts';
|
||||
import { observeAuthOperation } from '#lib/workspace/auth/operation.ts';
|
||||
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
|
||||
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||
@@ -12,10 +13,12 @@
|
||||
let verifiedLogin = $state(false);
|
||||
let error = $state('');
|
||||
const controller = new AbortController();
|
||||
let loginController: AbortController | null = null;
|
||||
let active = true;
|
||||
|
||||
let verifiedGeneration: number | null = null;
|
||||
const isCurrent = (generation: number) => active && generation === browserSession.authenticationGeneration;
|
||||
let verifiedStamp: string | null = null;
|
||||
const isCurrent = (generation: number, stamp: string | null) => active && generation === browserSession.authenticationGeneration && stamp === browserSession.authenticationStamp;
|
||||
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
|
||||
function matchesSession(expected: AuthUser): boolean {
|
||||
const snapshot = browserSession.snapshot;
|
||||
@@ -28,9 +31,10 @@
|
||||
return browserSession.subscribe(() => {
|
||||
// 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。
|
||||
// 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。
|
||||
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || !matchesSession(user)) {
|
||||
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || verifiedStamp !== browserSession.authenticationStamp || !matchesSession(user)) {
|
||||
verifiedLogin = false;
|
||||
verifiedGeneration = null;
|
||||
verifiedStamp = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -61,34 +65,56 @@
|
||||
error = '';
|
||||
sessionMutation();
|
||||
let generation = browserSession.authenticationGeneration;
|
||||
let stamp = browserSession.authenticationStamp;
|
||||
const operation = new AbortController();
|
||||
loginController = operation;
|
||||
let promptGeneration: number | null = generation;
|
||||
const unsubscribe = browserSession.subscribe(() => {
|
||||
if (promptGeneration !== null && !isCurrent(promptGeneration, stamp)) operation.abort();
|
||||
});
|
||||
try {
|
||||
const signedIn = await loginWithPasskey(controller.signal);
|
||||
if (!isCurrent(generation)) throw new Error('Login superseded');
|
||||
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) {
|
||||
const initial = await loadWhoami(operation.signal);
|
||||
if (!isCurrent(generation, stamp)) throw new Error('Login superseded');
|
||||
observeAuthOperation(operation.signal, {
|
||||
expected: initial, phase: () => {}, additionAttempt: () => {},
|
||||
verifySession: async () => {
|
||||
if (!isCurrent(generation, stamp)) operation.abort();
|
||||
operation.signal.throwIfAborted();
|
||||
const current = await loadWhoami(operation.signal);
|
||||
if (!isCurrent(generation, stamp) || initial.actor?.user_id !== current.actor?.user_id || initial.actor?.account_id !== current.actor?.account_id || initial.authentication_reference !== current.authentication_reference) throw new Error('Session changed');
|
||||
},
|
||||
});
|
||||
const signedIn = await loginWithPasskey(operation.signal);
|
||||
if (!isCurrent(generation, stamp)) throw new Error('Login superseded');
|
||||
if (signedIn.user.user_id !== expected.user_id || signedIn.user.account_id !== expected.account_id) {
|
||||
throw new Error('Unexpected login actor');
|
||||
}
|
||||
// Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。
|
||||
promptGeneration = null;
|
||||
sessionMutation();
|
||||
generation = browserSession.authenticationGeneration;
|
||||
stamp = browserSession.authenticationStamp;
|
||||
await browserSession.refresh();
|
||||
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session not verified');
|
||||
if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session not verified');
|
||||
const access = await loadHostingAccess();
|
||||
if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified');
|
||||
if (!isCurrent(generation, stamp) || !access.is_admin) throw new Error('Access not verified');
|
||||
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
|
||||
await browserSession.refresh();
|
||||
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session superseded');
|
||||
if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session superseded');
|
||||
verifiedGeneration = generation;
|
||||
verifiedStamp = stamp;
|
||||
verifiedLogin = true;
|
||||
notifySessionChanged();
|
||||
} catch {
|
||||
if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.';
|
||||
// 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを
|
||||
// invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。
|
||||
if (isCurrent(generation)) {
|
||||
if (isCurrent(generation, stamp)) {
|
||||
sessionMutation();
|
||||
await browserSession.refresh();
|
||||
}
|
||||
} finally {
|
||||
promptGeneration = null; unsubscribe(); loginController = null;
|
||||
if (active) busy = false;
|
||||
}
|
||||
}
|
||||
@@ -97,6 +123,7 @@
|
||||
active = false;
|
||||
token = '';
|
||||
controller.abort();
|
||||
loginController?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -36,37 +36,56 @@ function boundary(
|
||||
const create = vi.fn(async () => credentialFixture());
|
||||
const get = vi.fn(async () => credentialFixture(true));
|
||||
vi.stubGlobal("navigator", { credentials: { create, get } });
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: admin });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/options") {
|
||||
return status === 200
|
||||
? Response.json(registrationOptions)
|
||||
: new Response("host-test-token", { status });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/complete") {
|
||||
return Response.json({ user: authUser });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return failLogin
|
||||
? new Response(null, { status: 401 })
|
||||
: Response.json({ user: authUser });
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
async (input, init) => {
|
||||
const path = String(input);
|
||||
if (
|
||||
path === "/api/auth/whoami" &&
|
||||
new Headers(init?.headers).has("authorization")
|
||||
) {
|
||||
return status === 200
|
||||
? Response.json({
|
||||
actor: { ...authUser, auth_method: "api_token" },
|
||||
authentication_reference: "fixture-bearer-authentication",
|
||||
session: { kind: "authenticated", expires_at: null },
|
||||
})
|
||||
: new Response("host-test-token", { status });
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: actor ? "fixture-authentication" : null,
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: admin });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/options") {
|
||||
return status === 200
|
||||
? Response.json(registrationOptions)
|
||||
: new Response("host-test-token", { status });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/complete") {
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return failLogin ? new Response(null, { status: 401 }) : Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return { fetcher, create, get };
|
||||
}
|
||||
@@ -117,8 +136,10 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
|
||||
for (const [path, init] of requests) {
|
||||
expect(String(path)).not.toContain("host-test-token");
|
||||
expect(String(init?.body)).not.toContain("host-test-token");
|
||||
expect(init?.credentials).toBe("omit");
|
||||
expect(new Headers(init?.headers).has("cookie")).toBe(false);
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe(
|
||||
String(path).includes("/addition/") ? "Bearer host-test-token" : null,
|
||||
"Bearer host-test-token",
|
||||
);
|
||||
}
|
||||
await fireEvent.click(
|
||||
@@ -130,7 +151,7 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
|
||||
expect(settings.getAttribute("href")).toBe("/hosting/settings");
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(2);
|
||||
.toHaveLength(6);
|
||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
|
||||
.toHaveLength(1);
|
||||
expect(
|
||||
@@ -179,8 +200,10 @@ test("a different cookie actor cannot be accepted as the enrolled administrator"
|
||||
const { fetcher } = boundary();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
String(input) === "/api/auth/whoami" &&
|
||||
!new Headers(init?.headers).has("authorization")
|
||||
? Response.json({
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: {
|
||||
...authUser,
|
||||
@@ -221,7 +244,12 @@ test("leaving during the passkey prompt aborts enrollment and prevents later com
|
||||
unmount();
|
||||
expect(signal.aborted).toBe(true);
|
||||
resolve(credentialFixture());
|
||||
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(fetcher.mock.calls.map(([path]) => path)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/options",
|
||||
])
|
||||
);
|
||||
const { unmount: unmountAgain } = render(Setup);
|
||||
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
@@ -248,7 +276,7 @@ test("completion response loss does not claim success and points to login before
|
||||
);
|
||||
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
|
||||
.toBeNull();
|
||||
});
|
||||
@@ -259,6 +287,7 @@ test("API token identity is not mistaken for a verified browser session", async
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
? Response.json({
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: { ...authUser, auth_method: "api_token" },
|
||||
})
|
||||
@@ -292,6 +321,7 @@ const replacedActor = {
|
||||
function identityResponse(actor: typeof setupActor | null) {
|
||||
return Response.json({
|
||||
actor,
|
||||
authentication_reference: actor ? "fixture-authentication" : null,
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -317,15 +347,25 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
|
||||
const started = gate<void>();
|
||||
const pending = gate<Response>();
|
||||
let current: typeof setupActor | null = setupActor;
|
||||
let reads = 0;
|
||||
let loginCompleted = false;
|
||||
let accessRead = false;
|
||||
let held = false;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
const path = String(input);
|
||||
// Bearer identity resolution belongs to enrollment, never the cookie-login gates.
|
||||
if (new Headers(init?.headers).has("authorization")) {
|
||||
return original(input, init);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") loginCompleted = true;
|
||||
if (path === "/api/hosting/access") accessRead = true;
|
||||
if (path === "/api/auth/whoami") {
|
||||
++reads;
|
||||
if (
|
||||
(phase === "whoami" && reads === 1) ||
|
||||
(phase === "confirmation" && reads === 2)
|
||||
!held && loginCompleted && (
|
||||
(phase === "whoami" && !accessRead) ||
|
||||
(phase === "confirmation" && accessRead)
|
||||
)
|
||||
) {
|
||||
held = true;
|
||||
started.resolve();
|
||||
return pending.promise;
|
||||
}
|
||||
@@ -357,7 +397,10 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
|
||||
phase === "access"
|
||||
? Response.json({ is_admin: true })
|
||||
: phase === "login"
|
||||
? Response.json({ user: authUser })
|
||||
? Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
})
|
||||
: identityResponse(setupActor),
|
||||
);
|
||||
await waitFor(() =>
|
||||
@@ -421,3 +464,154 @@ for (const replacement of [null, replacedActor]) {
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
test("setup logout during native login prompt aborts and never submits the late credential", async () => {
|
||||
const { get, fetcher } = boundary();
|
||||
const prompted = gate<void>();
|
||||
const credential = gate<Credential>();
|
||||
get.mockImplementation(() => {
|
||||
prompted.resolve();
|
||||
return credential.promise;
|
||||
});
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await prompted.promise;
|
||||
await changedInAnotherTab(null);
|
||||
const signal = (get.mock.calls[0] as unknown as [CredentialRequestOptions])[0]
|
||||
.signal!;
|
||||
expect(signal.aborted).toBe(true);
|
||||
credential.resolve(credentialFixture(true));
|
||||
await screen.findByRole("alert");
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) =>
|
||||
path === "/api/auth/passkeys/login/complete"
|
||||
),
|
||||
).toBe(false);
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("setup final confirmation rejects replacement session for the same owner", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
let accessRead = false;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
if (String(input) === "/api/hosting/access") accessRead = true;
|
||||
if (String(input) === "/api/auth/whoami" && accessRead) {
|
||||
return Response.json({
|
||||
actor: setupActor,
|
||||
authentication_reference: "same-owner-different-session",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
});
|
||||
}
|
||||
return await request(input, init);
|
||||
});
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
for (
|
||||
const phase of [
|
||||
"initial",
|
||||
"options",
|
||||
"prompt",
|
||||
"pre-complete",
|
||||
"complete",
|
||||
"whoami",
|
||||
"access",
|
||||
"confirmation",
|
||||
] as const
|
||||
) {
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
test(`setup fences shared ${reason} stamp during ${phase} before event delivery`, async () => {
|
||||
const { fetcher, get } = boundary();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
const started = gate<void>();
|
||||
const pending = gate<void>();
|
||||
let optionsRead = false,
|
||||
promptReturned = false,
|
||||
completionSent = false,
|
||||
accessRead = false,
|
||||
held = false;
|
||||
const hold = async () => {
|
||||
held = true;
|
||||
started.resolve();
|
||||
await pending.promise;
|
||||
};
|
||||
get.mockImplementation(async () => {
|
||||
if (phase === "prompt") await hold();
|
||||
promptReturned = true;
|
||||
return credentialFixture(true);
|
||||
});
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
if (new Headers(init?.headers).has("authorization")) {
|
||||
return await request(input, init);
|
||||
}
|
||||
const path = String(input);
|
||||
const point = path === "/api/auth/whoami"
|
||||
? !optionsRead
|
||||
? "initial"
|
||||
: !completionSent && promptReturned
|
||||
? "pre-complete"
|
||||
: accessRead
|
||||
? "confirmation"
|
||||
: "whoami"
|
||||
: path.endsWith("/login/options")
|
||||
? "options"
|
||||
: path.endsWith("/login/complete")
|
||||
? "complete"
|
||||
: path === "/api/hosting/access"
|
||||
? "access"
|
||||
: null;
|
||||
if (point === "options") optionsRead = true;
|
||||
if (point === "complete") completionSent = true;
|
||||
if (point === "access") accessRead = true;
|
||||
const response = await request(input, init);
|
||||
if (!held && point === phase) await hold();
|
||||
return response;
|
||||
});
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await started.promise;
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = JSON.stringify({ id: crypto.randomUUID(), reason });
|
||||
// 同一documentのsetItemはstorage eventを配送しない。generationを直接変えず順序を固定する。
|
||||
localStorage.setItem("yoi.browser-auth-generation", stamp);
|
||||
expect(browserSession.authenticationGeneration).toBe(generation);
|
||||
pending.resolve();
|
||||
await screen.findByRole("alert");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Sign in with new passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false)
|
||||
);
|
||||
expect(
|
||||
fetcher.mock.calls.filter(([path]) =>
|
||||
path === "/api/auth/passkeys/login/complete"
|
||||
),
|
||||
).toHaveLength(
|
||||
["complete", "whoami", "access", "confirmation"].includes(phase)
|
||||
? 1
|
||||
: 0,
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
// 古いcatchが新tabのstampを自身のsessionMutationで上書きしない。
|
||||
expect(browserSession.authenticationStamp).toBe(stamp);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user