phase 1.6: add consent-record endpoint POST /biometric/subject/{id}/consent (Gate 2 backend)
Backs the candidate-facing consent flow specified in
docs/policies/consent/biometric_consent_template_v1.md §5. Without
this endpoint, no production code path could flip
consent.biometric.status from NeverCollected/Pending to Given —
meaning every photo upload would fail-closed at the consent gate
(403 consent_required) forever, even after counsel signs the
template. This unblocks the post-cutover intake flow.
Endpoint shape:
- POST /biometric/subject/{candidate_id}/consent
- Auth: legal-tier (X-Lakehouse-Legal-Token header)
- Body (JSON): {consent_version_hash, consent_collection_method,
consent_collection_evidence_path?, operator_of_record}
- consent_collection_method constrained to closed set:
electronic_signature / paper / click_acceptance — operator
typo would silently weaken evidentiary defensibility
- consent_version_hash recorded but not gated against allowlist
at runtime (avoids config-deployment dependency for v2 template
rotation; counsel validates retroactively against
consent_versions table)
State-machine semantics (mirrors upload's double-upload guard):
- NeverCollected / Pending → Given (happy path)
- Given → 409 consent_already_given (re-collect requires explicit
erase + fresh grant under new template version)
- Withdrawn / Expired → 409 consent_post_withdrawal_requires_erase
(explicit erase preserves audit-chain ordering of the cycle)
- Subject status Withdrawn / Erased / RetentionExpired → 403
Server-side authoritative timestamps:
- given_at = Utc::now() (operator-supplied would be tamperable)
- retention_until = given_at + 540 days (18 months per retention
schedule v1 §4). If counsel changes the cap, schedule doc + code
constant CONSENT_RETENTION_DAYS change in the same PR.
Audit row:
- accessor.kind = "biometric_consent_grant"
- accessor.purpose = "version=<hash>;method=<m>;operator=<op>;evidence=<path>"
- fields_accessed = ["consent.biometric.status",
"consent.biometric.retention_until"]
- result = "given"
- Transactional: manifest commit before audit append; rollback
manifest if audit fails.
Tests added (12 new):
- consent_happy_path_flips_status_and_records_audit (full happy
path + audit row inspection + retention_until math)
- consent_missing_token_rejected
- consent_wrong_token_rejected
- consent_subject_not_found_returns_404
- consent_missing_version_hash_400
- consent_missing_method_400
- consent_invalid_method_400
- consent_missing_operator_400
- consent_already_given_returns_409
- consent_post_withdrawal_requires_erase_returns_409
- consent_subject_inactive_returns_403
- consent_grant_then_upload_is_the_intended_intake_flow
(end-to-end: grant → upload → verify chain has 2 rows
consent_grant→biometric_collection in correct order, upload
row chains off the grant's hmac)
71/71 catalogd lib tests + gateway crate compile clean.
Live verification post-restart:
- /audit/health + /biometric/health both 200
- Live POST returns 400 on missing/malformed body, 404 on ghost
subject — auth + body parse + subject lookup ordering verified
- strings(target/release/gateway) contains: record_consent symbol,
biometric_consent_response.v1, biometric_consent_grant,
consent_already_given, consent_post_withdrawal_requires_erase,
electronic_signature, /subject/{candidate_id}/consent route
Production posture: gateway running with the new endpoint live.
The candidate-facing consent UI is NOT yet built; that is a
separate session. This endpoint is the backend the UI will call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b2c34b80b3
commit
76cb5acb03
@ -123,6 +123,7 @@ impl BiometricEndpointState {
|
|||||||
pub fn router(state: BiometricEndpointState) -> Router {
|
pub fn router(state: BiometricEndpointState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/subject/{candidate_id}/photo", post(upload_photo))
|
.route("/subject/{candidate_id}/photo", post(upload_photo))
|
||||||
|
.route("/subject/{candidate_id}/consent", post(record_consent))
|
||||||
.route("/subject/{candidate_id}/erase", post(erase_subject))
|
.route("/subject/{candidate_id}/erase", post(erase_subject))
|
||||||
.route("/health", get(biometric_health))
|
.route("/health", get(biometric_health))
|
||||||
.layer(DefaultBodyLimit::max(MAX_PHOTO_BYTES))
|
.layer(DefaultBodyLimit::max(MAX_PHOTO_BYTES))
|
||||||
@ -779,6 +780,298 @@ pub async fn process_erase(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Consent recording (Gate 2 backend) ─────────────────────────────
|
||||||
|
//
|
||||||
|
// Backs the candidate-facing consent flow specified in
|
||||||
|
// `docs/policies/consent/biometric_consent_template_v1.md` §5. Every
|
||||||
|
// candidate whose photo is later uploaded MUST first have a manifest
|
||||||
|
// with `consent.biometric.status = Given` — produced by this endpoint.
|
||||||
|
//
|
||||||
|
// Auth: legal-tier (operator records on behalf of candidate). When the
|
||||||
|
// candidate-facing intake UI ships, this endpoint stays operator-side;
|
||||||
|
// the UI captures the signature + posts here. The candidate's
|
||||||
|
// browser/app NEVER touches this endpoint directly.
|
||||||
|
//
|
||||||
|
// Idempotency posture:
|
||||||
|
// - if subject is at NeverCollected / Pending → flip to Given (happy path)
|
||||||
|
// - if subject is at Given → 409 (already given; consent doesn't
|
||||||
|
// re-stamp). Re-collect requires explicit erase first.
|
||||||
|
// - if subject is at Withdrawn / Expired → 409 (must erase residue
|
||||||
|
// of the prior cycle before granting a fresh consent)
|
||||||
|
//
|
||||||
|
// Retention clock: 18 months from given_at, per retention schedule v1
|
||||||
|
// §4. If counsel changes the cap, this constant + the retention doc
|
||||||
|
// must change in the same PR (intentional friction).
|
||||||
|
|
||||||
|
const CONSENT_RESPONSE_SCHEMA: &str = "biometric_consent_response.v1";
|
||||||
|
const CONSENT_RETENTION_DAYS: i64 = 18 * 30; // 540 days ≈ 18 months per retention schedule §4
|
||||||
|
|
||||||
|
const ALLOWED_CONSENT_METHODS: &[&str] = &["electronic_signature", "paper", "click_acceptance"];
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, Debug, Clone)]
|
||||||
|
pub struct ConsentRequest {
|
||||||
|
/// SHA-256 of the consent template version the candidate signed
|
||||||
|
/// under. Required + non-empty. Recorded in the audit row for
|
||||||
|
/// legal traceability; counsel validates retroactively against the
|
||||||
|
/// known good consent_versions table. We deliberately do NOT gate
|
||||||
|
/// against an in-process allowlist — that would couple the endpoint
|
||||||
|
/// to a config-deployment dependency for every template rotation.
|
||||||
|
pub consent_version_hash: String,
|
||||||
|
/// Collection method per consent template §5. Constrained to a
|
||||||
|
/// closed set (electronic_signature / paper / click_acceptance)
|
||||||
|
/// because counsel needs to know what evidence shape was captured
|
||||||
|
/// and operator typo would silently weaken defensibility.
|
||||||
|
pub consent_collection_method: String,
|
||||||
|
/// Path or URL to the signed artifact (PDF receipt, e-sig audit
|
||||||
|
/// log, scanned paper, etc.). Recorded in the audit row for
|
||||||
|
/// chain-of-custody. May be empty for click_acceptance where the
|
||||||
|
/// "evidence" is the click + timestamp + IP captured upstream.
|
||||||
|
#[serde(default)]
|
||||||
|
pub consent_collection_evidence_path: String,
|
||||||
|
/// Operator initiating the consent record (the staffing coordinator
|
||||||
|
/// who collected the signature, or the intake-UI service principal).
|
||||||
|
/// Required for audit traceability — every grant must be attributable.
|
||||||
|
pub operator_of_record: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
pub struct ConsentResponse {
|
||||||
|
pub schema: &'static str,
|
||||||
|
pub candidate_id: String,
|
||||||
|
pub status_after: String,
|
||||||
|
pub consent_version_hash: String,
|
||||||
|
pub given_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub retention_until: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub audit_row_hmac: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_consent(
|
||||||
|
State(state): State<BiometricEndpointState>,
|
||||||
|
Path(candidate_id): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let auth_token = headers
|
||||||
|
.get(LEGAL_TOKEN_HEADER)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let trace_id = headers
|
||||||
|
.get(TRACE_ID_HEADER)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let req: ConsentRequest = match serde_json::from_slice(&body) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => return (StatusCode::BAD_REQUEST, Json(ErrorResponse {
|
||||||
|
error: "bad_request",
|
||||||
|
detail: format!(
|
||||||
|
"consent body must be JSON {{consent_version_hash, consent_collection_method, operator_of_record, ...}}: {e}"
|
||||||
|
),
|
||||||
|
consent_status: None,
|
||||||
|
})).into_response(),
|
||||||
|
};
|
||||||
|
match process_consent(&state, &candidate_id, auth_token.as_deref(), &trace_id, req).await {
|
||||||
|
Ok(resp) => (StatusCode::OK, Json(resp)).into_response(),
|
||||||
|
Err((status, err)) => (status, Json(err)).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure logic for consent recording — same testable shape as
|
||||||
|
/// `process_upload` and `process_erase`. Inputs are the raw header
|
||||||
|
/// values + parsed body; output is either the success body or
|
||||||
|
/// (status, error body). Manifest + audit-writer interactions stay
|
||||||
|
/// in here so the test surface IS the behavior.
|
||||||
|
pub async fn process_consent(
|
||||||
|
state: &BiometricEndpointState,
|
||||||
|
candidate_id: &str,
|
||||||
|
legal_token: Option<&str>,
|
||||||
|
trace_id: &str,
|
||||||
|
req: ConsentRequest,
|
||||||
|
) -> Result<ConsentResponse, (StatusCode, ErrorResponse)> {
|
||||||
|
// Auth (mirrors process_upload / process_erase).
|
||||||
|
let configured = state.legal_token.as_ref().ok_or((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
ErrorResponse { error: "auth_failed", detail: "no legal token configured".into(), consent_status: None },
|
||||||
|
))?;
|
||||||
|
let provided = legal_token.ok_or((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
ErrorResponse { error: "auth_failed", detail: "missing X-Lakehouse-Legal-Token".into(), consent_status: None },
|
||||||
|
))?;
|
||||||
|
if !constant_time_eq(provided.as_bytes(), configured.as_bytes()) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
ErrorResponse { error: "auth_failed", detail: "X-Lakehouse-Legal-Token mismatch".into(), consent_status: None },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if candidate_id.is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||||
|
error: "bad_request", detail: "candidate_id is empty".into(), consent_status: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body validation. Empty / whitespace-only fields would silently
|
||||||
|
// weaken defensibility (a consent record without a version hash is
|
||||||
|
// not a defensible consent record).
|
||||||
|
if req.consent_version_hash.trim().is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||||
|
error: "bad_request",
|
||||||
|
detail: "consent_version_hash is required (SHA-256 of the consent template version the candidate signed under)".into(),
|
||||||
|
consent_status: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if req.operator_of_record.trim().is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||||
|
error: "bad_request",
|
||||||
|
detail: "operator_of_record is required".into(),
|
||||||
|
consent_status: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let method = req.consent_collection_method.trim();
|
||||||
|
if method.is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||||
|
error: "bad_request",
|
||||||
|
detail: format!(
|
||||||
|
"consent_collection_method is required (one of: {})",
|
||||||
|
ALLOWED_CONSENT_METHODS.join(", "),
|
||||||
|
),
|
||||||
|
consent_status: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if !ALLOWED_CONSENT_METHODS.contains(&method) {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||||
|
error: "bad_request",
|
||||||
|
detail: format!(
|
||||||
|
"consent_collection_method must be one of: {} (got {})",
|
||||||
|
ALLOWED_CONSENT_METHODS.join(", "),
|
||||||
|
method,
|
||||||
|
),
|
||||||
|
consent_status: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load original manifest.
|
||||||
|
let original_manifest = state.registry.get_subject(candidate_id).await.ok_or((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
ErrorResponse {
|
||||||
|
error: "subject_not_found",
|
||||||
|
detail: format!("no SubjectManifest for {candidate_id}"),
|
||||||
|
consent_status: None,
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
let mut manifest = original_manifest.clone();
|
||||||
|
|
||||||
|
use shared::types::{BiometricConsentStatus, SubjectStatus};
|
||||||
|
|
||||||
|
// Subject must be Active. Withdrawn/Erased/RetentionExpired subjects
|
||||||
|
// need explicit re-activation upstream before consent can be recorded.
|
||||||
|
if matches!(manifest.status, SubjectStatus::Withdrawn | SubjectStatus::Erased | SubjectStatus::RetentionExpired) {
|
||||||
|
return Err((StatusCode::FORBIDDEN, ErrorResponse {
|
||||||
|
error: "subject_inactive",
|
||||||
|
detail: format!("subject status {:?} — consent grant not permitted", manifest.status),
|
||||||
|
consent_status: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// State-machine transitions. Mirrors the upload double-upload guard
|
||||||
|
// (refuse re-grant; force explicit erase + re-consent cycle).
|
||||||
|
match manifest.consent.biometric.status {
|
||||||
|
BiometricConsentStatus::Given => {
|
||||||
|
return Err((StatusCode::CONFLICT, ErrorResponse {
|
||||||
|
error: "consent_already_given",
|
||||||
|
detail: "subject already has biometric consent given; \
|
||||||
|
POST /biometric/subject/{id}/erase first if you intend to re-collect under a new consent version".into(),
|
||||||
|
consent_status: Some("Given".into()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
BiometricConsentStatus::Withdrawn | BiometricConsentStatus::Expired => {
|
||||||
|
return Err((StatusCode::CONFLICT, ErrorResponse {
|
||||||
|
error: "consent_post_withdrawal_requires_erase",
|
||||||
|
detail: format!(
|
||||||
|
"subject biometric.status is {:?}; explicit erase required before fresh consent grant \
|
||||||
|
(so any residual collection state is cleared and the audit chain records the cycle).",
|
||||||
|
manifest.consent.biometric.status,
|
||||||
|
),
|
||||||
|
consent_status: Some(format!("{:?}", manifest.consent.biometric.status)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
BiometricConsentStatus::NeverCollected | BiometricConsentStatus::Pending => {
|
||||||
|
// Happy path — proceed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute timestamps. given_at = now (server-side authoritative —
|
||||||
|
// operator-supplied timestamp would be tamperable). retention_until
|
||||||
|
// = given_at + 18 months per retention schedule v1 §4.
|
||||||
|
let given_at = chrono::Utc::now();
|
||||||
|
let retention_until = given_at + chrono::Duration::days(CONSENT_RETENTION_DAYS);
|
||||||
|
|
||||||
|
manifest.consent.biometric.status = BiometricConsentStatus::Given;
|
||||||
|
manifest.consent.biometric.retention_until = Some(retention_until);
|
||||||
|
manifest.updated_at = given_at;
|
||||||
|
|
||||||
|
// Transactional commit. Same posture as upload + erase: manifest
|
||||||
|
// commit before audit row, rollback manifest if audit fails.
|
||||||
|
if let Err(e) = state.registry.put_subject(manifest.clone()).await {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
ErrorResponse { error: "manifest_update_failed", detail: e, consent_status: None },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = SubjectAuditRow {
|
||||||
|
schema: "subject_audit.v1".into(),
|
||||||
|
ts: given_at,
|
||||||
|
candidate_id: candidate_id.to_string(),
|
||||||
|
accessor: AuditAccessor {
|
||||||
|
kind: "biometric_consent_grant".into(),
|
||||||
|
daemon: "gateway".into(),
|
||||||
|
purpose: format!(
|
||||||
|
"version={};method={};operator={};evidence={}",
|
||||||
|
req.consent_version_hash, method, req.operator_of_record, req.consent_collection_evidence_path,
|
||||||
|
),
|
||||||
|
trace_id: trace_id.to_string(),
|
||||||
|
},
|
||||||
|
fields_accessed: vec![
|
||||||
|
"consent.biometric.status".into(),
|
||||||
|
"consent.biometric.retention_until".into(),
|
||||||
|
],
|
||||||
|
result: "given".into(),
|
||||||
|
prev_chain_hash: String::new(),
|
||||||
|
row_hmac: String::new(),
|
||||||
|
};
|
||||||
|
let audit_row_hmac = match state.writer.append(row).await {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(e) => {
|
||||||
|
// Audit failed — roll back manifest to original state. The
|
||||||
|
// grant is best understood as "didn't happen" until the
|
||||||
|
// audit chain says it did. Rollback errors are logged but
|
||||||
|
// don't propagate; the user-facing failure is the audit.
|
||||||
|
tracing::error!("consent audit row failed for {candidate_id}: {e}; rolling back");
|
||||||
|
if let Err(re_err) = state.registry.put_subject(original_manifest).await {
|
||||||
|
tracing::error!("rollback: could not revert manifest for {candidate_id}: {re_err}");
|
||||||
|
}
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
ErrorResponse {
|
||||||
|
error: "audit_write_failed",
|
||||||
|
detail: format!("audit row failed; manifest rolled back: {e}"),
|
||||||
|
consent_status: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ConsentResponse {
|
||||||
|
schema: CONSENT_RESPONSE_SCHEMA,
|
||||||
|
candidate_id: candidate_id.to_string(),
|
||||||
|
status_after: "Given".into(),
|
||||||
|
consent_version_hash: req.consent_version_hash,
|
||||||
|
given_at,
|
||||||
|
retention_until,
|
||||||
|
audit_row_hmac,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -1271,4 +1564,213 @@ mod tests {
|
|||||||
assert_eq!(rows[1].prev_chain_hash, upload.audit_row_hmac);
|
assert_eq!(rows[1].prev_chain_hash, upload.audit_row_hmac);
|
||||||
assert_eq!(writer.verify_chain("WORKER-E9").await.unwrap(), 2);
|
assert_eq!(writer.verify_chain("WORKER-E9").await.unwrap(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Consent recording tests (Gate 2 backend) ──────────────────
|
||||||
|
|
||||||
|
fn fixture_consent_request() -> ConsentRequest {
|
||||||
|
ConsentRequest {
|
||||||
|
consent_version_hash: "abcdef0123456789".repeat(4), // 64-char fake SHA-256
|
||||||
|
consent_collection_method: "electronic_signature".into(),
|
||||||
|
consent_collection_evidence_path: "/tmp/sig_receipt_WORKER-X.pdf".into(),
|
||||||
|
operator_of_record: "OperatorA".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_happy_path_flips_status_and_records_audit() {
|
||||||
|
// Subject starts at NeverCollected. Consent grant flips to Given,
|
||||||
|
// sets retention_until = given_at + 18 months, appends a
|
||||||
|
// biometric_consent_grant audit row, returns 200 with the grant
|
||||||
|
// metadata + chained hmac.
|
||||||
|
let state = fixture_state("consent_happy").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C1", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||||
|
let writer = state.writer.clone();
|
||||||
|
let registry = state.registry.clone();
|
||||||
|
|
||||||
|
let resp = process_consent(&state, "WORKER-C1", Some(TEST_TOKEN), "trace-c1", fixture_consent_request())
|
||||||
|
.await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.schema, "biometric_consent_response.v1");
|
||||||
|
assert_eq!(resp.candidate_id, "WORKER-C1");
|
||||||
|
assert_eq!(resp.status_after, "Given");
|
||||||
|
assert_eq!(resp.consent_version_hash.len(), 64);
|
||||||
|
assert!(!resp.audit_row_hmac.is_empty());
|
||||||
|
// retention_until is given_at + 18 months (540 days).
|
||||||
|
let elapsed = resp.retention_until - resp.given_at;
|
||||||
|
assert_eq!(elapsed.num_days(), CONSENT_RETENTION_DAYS);
|
||||||
|
|
||||||
|
// Manifest reflects the grant. Note: registry::put_subject
|
||||||
|
// assigns its OWN `updated_at = Utc::now()`, so the manifest's
|
||||||
|
// updated_at lands a few microseconds after the response's
|
||||||
|
// given_at — assert ordering, not equality.
|
||||||
|
let m = registry.get_subject("WORKER-C1").await.unwrap();
|
||||||
|
assert_eq!(m.consent.biometric.status, BiometricConsentStatus::Given);
|
||||||
|
assert_eq!(m.consent.biometric.retention_until, Some(resp.retention_until));
|
||||||
|
assert!(m.updated_at >= resp.given_at,
|
||||||
|
"manifest.updated_at ({}) should be >= response.given_at ({})", m.updated_at, resp.given_at);
|
||||||
|
|
||||||
|
// Audit row is correctly shaped.
|
||||||
|
assert_eq!(writer.verify_chain("WORKER-C1").await.unwrap(), 1);
|
||||||
|
let rows = writer.read_rows_in_range("WORKER-C1", None, None).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].accessor.kind, "biometric_consent_grant");
|
||||||
|
assert!(rows[0].accessor.purpose.contains("version="));
|
||||||
|
assert!(rows[0].accessor.purpose.contains("method=electronic_signature"));
|
||||||
|
assert!(rows[0].accessor.purpose.contains("operator=OperatorA"));
|
||||||
|
assert_eq!(rows[0].accessor.trace_id, "trace-c1");
|
||||||
|
assert_eq!(rows[0].fields_accessed, vec!["consent.biometric.status".to_string(), "consent.biometric.retention_until".to_string()]);
|
||||||
|
assert_eq!(rows[0].result, "given");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_missing_token_rejected() {
|
||||||
|
let state = fixture_state("consent_no_token").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C2", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||||
|
let err = process_consent(&state, "WORKER-C2", None, "", fixture_consent_request())
|
||||||
|
.await.unwrap_err();
|
||||||
|
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
|
||||||
|
assert_eq!(err.1.error, "auth_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_wrong_token_rejected() {
|
||||||
|
let state = fixture_state("consent_wrong_token").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C3", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||||
|
let err = process_consent(&state, "WORKER-C3", Some("nope-not-the-token"), "", fixture_consent_request())
|
||||||
|
.await.unwrap_err();
|
||||||
|
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_subject_not_found_returns_404() {
|
||||||
|
let state = fixture_state("consent_no_subject").await;
|
||||||
|
let err = process_consent(&state, "GHOST-WORKER", Some(TEST_TOKEN), "", fixture_consent_request())
|
||||||
|
.await.unwrap_err();
|
||||||
|
assert_eq!(err.0, StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(err.1.error, "subject_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_missing_version_hash_400() {
|
||||||
|
let state = fixture_state("consent_no_version").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C4", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||||
|
let mut req = fixture_consent_request();
|
||||||
|
req.consent_version_hash = " ".into();
|
||||||
|
let err = process_consent(&state, "WORKER-C4", Some(TEST_TOKEN), "", req)
|
||||||
|
.await.unwrap_err();
|
||||||
|
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(err.1.detail.contains("consent_version_hash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_missing_method_400() {
|
||||||
|
let state = fixture_state("consent_no_method").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C5", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||||
|
let mut req = fixture_consent_request();
|
||||||
|
req.consent_collection_method = "".into();
|
||||||
|
let err = process_consent(&state, "WORKER-C5", Some(TEST_TOKEN), "", req)
|
||||||
|
.await.unwrap_err();
|
||||||
|
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(err.1.detail.contains("consent_collection_method"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_invalid_method_400() {
|
||||||
|
// Methods are constrained to a closed set so operator typos
|
||||||
|
// can't silently weaken evidentiary defensibility (e.g.
|
||||||
|
// "electric_signature" vs "electronic_signature").
|
||||||
|
let state = fixture_state("consent_bad_method").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C6", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||||
|
let mut req = fixture_consent_request();
|
||||||
|
req.consent_collection_method = "carrier_pigeon".into();
|
||||||
|
let err = process_consent(&state, "WORKER-C6", Some(TEST_TOKEN), "", req)
|
||||||
|
.await.unwrap_err();
|
||||||
|
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(err.1.detail.contains("must be one of"));
|
||||||
|
assert!(err.1.detail.contains("carrier_pigeon"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_missing_operator_400() {
|
||||||
|
let state = fixture_state("consent_no_op").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C7", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||||
|
let mut req = fixture_consent_request();
|
||||||
|
req.operator_of_record = "".into();
|
||||||
|
let err = process_consent(&state, "WORKER-C7", Some(TEST_TOKEN), "", req)
|
||||||
|
.await.unwrap_err();
|
||||||
|
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert!(err.1.detail.contains("operator_of_record"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_already_given_returns_409() {
|
||||||
|
// Re-granting consent when status is already Given is refused.
|
||||||
|
// The expected flow for re-collection is: erase → grant fresh
|
||||||
|
// consent under the new template version. This forces the
|
||||||
|
// erase audit row to land before any new grant, preserving
|
||||||
|
// chain ordering.
|
||||||
|
let state = fixture_state("consent_already").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C8", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
|
||||||
|
let err = process_consent(&state, "WORKER-C8", Some(TEST_TOKEN), "", fixture_consent_request())
|
||||||
|
.await.unwrap_err();
|
||||||
|
assert_eq!(err.0, StatusCode::CONFLICT);
|
||||||
|
assert_eq!(err.1.error, "consent_already_given");
|
||||||
|
assert_eq!(err.1.consent_status.as_deref(), Some("Given"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_post_withdrawal_requires_erase_returns_409() {
|
||||||
|
// After Withdrawn, fresh consent requires explicit erase first.
|
||||||
|
// Otherwise the audit chain wouldn't capture the cycle of
|
||||||
|
// withdraw → erase → grant; the grant would land directly on
|
||||||
|
// a manifest that still references the prior collection.
|
||||||
|
let state = fixture_state("consent_after_withdraw").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C9", BiometricConsentStatus::Withdrawn, SubjectStatus::Active)).await;
|
||||||
|
let err = process_consent(&state, "WORKER-C9", Some(TEST_TOKEN), "", fixture_consent_request())
|
||||||
|
.await.unwrap_err();
|
||||||
|
assert_eq!(err.0, StatusCode::CONFLICT);
|
||||||
|
assert_eq!(err.1.error, "consent_post_withdrawal_requires_erase");
|
||||||
|
assert_eq!(err.1.consent_status.as_deref(), Some("Withdrawn"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_subject_inactive_returns_403() {
|
||||||
|
// Subject status takes precedence over consent status — an
|
||||||
|
// Erased subject can't be re-consented even if biometric.status
|
||||||
|
// happens to be NeverCollected (which it would be post-erasure).
|
||||||
|
let state = fixture_state("consent_inactive").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C10", BiometricConsentStatus::NeverCollected, SubjectStatus::Erased)).await;
|
||||||
|
let err = process_consent(&state, "WORKER-C10", Some(TEST_TOKEN), "", fixture_consent_request())
|
||||||
|
.await.unwrap_err();
|
||||||
|
assert_eq!(err.0, StatusCode::FORBIDDEN);
|
||||||
|
assert_eq!(err.1.error, "subject_inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn consent_grant_then_upload_is_the_intended_intake_flow() {
|
||||||
|
// End-to-end check of the post-cutover flow: candidate signs
|
||||||
|
// consent (process_consent flips status to Given) → operator
|
||||||
|
// uploads photo (process_upload now passes the consent gate).
|
||||||
|
// Audit chain captures: consent_grant → biometric_collection.
|
||||||
|
let state = fixture_state("consent_then_upload").await;
|
||||||
|
let _ = state.registry.put_subject(fixture_manifest("WORKER-C11", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||||
|
let writer = state.writer.clone();
|
||||||
|
|
||||||
|
// 1. Consent grant.
|
||||||
|
let grant = process_consent(&state, "WORKER-C11", Some(TEST_TOKEN), "trace-grant", fixture_consent_request())
|
||||||
|
.await.unwrap();
|
||||||
|
|
||||||
|
// 2. Photo upload — should succeed now.
|
||||||
|
let upload = process_upload(&state, "WORKER-C11", Some(TEST_TOKEN), Some("image/jpeg"), &grant.consent_version_hash, "trace-upload", &jpeg_bytes())
|
||||||
|
.await.unwrap();
|
||||||
|
assert_eq!(upload.consent_version_hash, grant.consent_version_hash);
|
||||||
|
|
||||||
|
// Audit chain has 2 rows in the right order.
|
||||||
|
assert_eq!(writer.verify_chain("WORKER-C11").await.unwrap(), 2);
|
||||||
|
let rows = writer.read_rows_in_range("WORKER-C11", None, None).await.unwrap();
|
||||||
|
assert_eq!(rows[0].accessor.kind, "biometric_consent_grant");
|
||||||
|
assert_eq!(rows[1].accessor.kind, "biometric_collection");
|
||||||
|
// Upload row chains off the consent grant's hmac.
|
||||||
|
assert_eq!(rows[1].prev_chain_hash, grant.audit_row_hmac);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user