mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -52,7 +52,12 @@ pub(crate) async fn get_request_audit_bundle(
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()).into_response())?;
|
||||
|
||||
match bundle {
|
||||
Some(bundle) => Ok(Json(bundle)),
|
||||
Some(mut bundle) => {
|
||||
if let Some(trace) = bundle.decision_trace.as_mut() {
|
||||
trace.sanitize_sensitive_diagnostics();
|
||||
}
|
||||
Ok(Json(bundle))
|
||||
}
|
||||
None => Err((
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) use aether_usage_runtime::{
|
||||
};
|
||||
pub(crate) use aether_usage_runtime::{UsageQueueHealthSnapshot, UsageRuntimeMetricsSnapshot};
|
||||
pub(crate) use reporting::{
|
||||
attach_internal_gateway_report_capability, resolve_bound_internal_gateway_report_context,
|
||||
spawn_sync_report, submit_stream_report, submit_sync_report, GatewayStreamReportRequest,
|
||||
GatewaySyncReportRequest,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_data_contracts::repository::video_tasks::VideoTaskLookupKey;
|
||||
use aether_usage_runtime::build_locally_actionable_report_context_from_video_task;
|
||||
use serde_json::Value;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use tokio::time::sleep;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::request_candidate_runtime::resolve_locally_actionable_request_candidate_report_context;
|
||||
use crate::video_tasks::{resolve_video_task_report_lookup, VideoTaskReportLookup};
|
||||
@@ -11,6 +17,312 @@ pub(crate) use aether_usage_runtime::report_context_is_locally_actionable;
|
||||
|
||||
const REQUEST_CANDIDATE_REPORT_CONTEXT_RETRY_ATTEMPTS: usize = 5;
|
||||
const REQUEST_CANDIDATE_REPORT_CONTEXT_RETRY_DELAY_MS: u64 = 50;
|
||||
const INTERNAL_REPORT_CAPABILITY_FIELD: &str = "_aether_internal_report_capability";
|
||||
const INTERNAL_REPORT_CAPABILITY_KEY_PREFIX: &str = "internal:gateway:report-capability:";
|
||||
const INTERNAL_REPORT_CAPABILITY_VERSION: u8 = 1;
|
||||
const INTERNAL_REPORT_CAPABILITY_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const INTERNAL_REPORT_CAPABILITY_MINT_ATTEMPTS: usize = 4;
|
||||
const PLAN_USAGE_RESERVATION_TOKEN_FIELD: &str = "plan_usage_reservation_token";
|
||||
const PLAN_USAGE_RESERVATION_DEFERRED_FIELD: &str = "plan_usage_reservation_deferred";
|
||||
|
||||
/// Fields produced while observing an upstream response. Everything else in the
|
||||
/// planner-issued context is immutable and covered by the capability digest.
|
||||
///
|
||||
/// This allowlist is intentionally top-level and fail-closed: adding a future
|
||||
/// report-context side effect requires explicitly classifying it as an observation.
|
||||
const INTERNAL_REPORT_OBSERVATION_FIELDS: &[&str] = &[
|
||||
"provider_response_headers",
|
||||
"provider_request_started_at_unix_ms",
|
||||
"provider_response_headers_observed_at_unix_ms",
|
||||
"provider_request_order_id",
|
||||
"client_response_status_code",
|
||||
"client_response_headers",
|
||||
"upstream_response",
|
||||
"error_flow",
|
||||
"transport_error",
|
||||
"input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"kiro_simulated_cache_enabled",
|
||||
"stage_timings_ms",
|
||||
"db_timings_ms",
|
||||
"end_to_end_time_ms",
|
||||
"end_to_end_first_byte_time_ms",
|
||||
"windsurf_native_runtime",
|
||||
"windsurf_language_server_port",
|
||||
];
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct InternalReportCapabilityRecord {
|
||||
version: u8,
|
||||
trace_id: String,
|
||||
report_scope: String,
|
||||
protected_context_sha256: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
kiro_web_search_context_sha256: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn attach_internal_gateway_report_capability(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
report_kind: Option<&str>,
|
||||
provider_request_headers: &BTreeMap<String, String>,
|
||||
report_context: &mut Option<Value>,
|
||||
) -> Result<(), crate::GatewayError> {
|
||||
let Some(report_kind) = report_kind else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(report_scope) = internal_report_capability_scope(report_kind) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(context) = report_context.as_mut().and_then(Value::as_object_mut) else {
|
||||
return Err(crate::GatewayError::Internal(
|
||||
"internal gateway report capability requires an object context".to_string(),
|
||||
));
|
||||
};
|
||||
if context.contains_key(INTERNAL_REPORT_CAPABILITY_FIELD) {
|
||||
return Err(crate::GatewayError::Internal(
|
||||
"internal gateway planner produced a reserved report capability field".to_string(),
|
||||
));
|
||||
}
|
||||
context.insert(
|
||||
"provider_request_headers".to_string(),
|
||||
serde_json::to_value(provider_request_headers)
|
||||
.map_err(|error| crate::GatewayError::Internal(error.to_string()))?,
|
||||
);
|
||||
|
||||
let protected_context_sha256 = protected_internal_report_context_sha256(context)?;
|
||||
let kiro_web_search_context_sha256 = kiro_web_search_internal_report_context_sha256(context)?;
|
||||
let record = InternalReportCapabilityRecord {
|
||||
version: INTERNAL_REPORT_CAPABILITY_VERSION,
|
||||
trace_id: trace_id.to_string(),
|
||||
report_scope,
|
||||
protected_context_sha256,
|
||||
kiro_web_search_context_sha256,
|
||||
};
|
||||
let serialized = serde_json::to_string(&record)
|
||||
.map_err(|error| crate::GatewayError::Internal(error.to_string()))?;
|
||||
|
||||
for _ in 0..INTERNAL_REPORT_CAPABILITY_MINT_ATTEMPTS {
|
||||
let capability = Uuid::new_v4().simple().to_string();
|
||||
let storage_key = internal_report_capability_storage_key(&capability);
|
||||
let inserted = state
|
||||
.runtime_state
|
||||
.kv_set_if_absent(
|
||||
&storage_key,
|
||||
serialized.clone(),
|
||||
INTERNAL_REPORT_CAPABILITY_TTL,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| crate::GatewayError::Internal(error.to_string()))?;
|
||||
if inserted {
|
||||
context.insert(
|
||||
INTERNAL_REPORT_CAPABILITY_FIELD.to_string(),
|
||||
Value::String(capability),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(crate::GatewayError::Internal(
|
||||
"failed to allocate a unique internal gateway report capability".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Validate and atomically consume a planner-issued report capability, then
|
||||
/// return a context whose planner fields are equivalent after canonical JSON
|
||||
/// normalization.
|
||||
///
|
||||
/// The internal request HMAC authenticates a peer, but a peer must not choose a
|
||||
/// candidate, user, provider key, video task, or file mapping target. The opaque
|
||||
/// capability is independent from diagnostic candidate persistence, so `terminal`
|
||||
/// and `none` persistence modes remain functional.
|
||||
pub(crate) async fn resolve_bound_internal_gateway_report_context(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
report_kind: &str,
|
||||
report_context: Option<&Value>,
|
||||
) -> Result<Option<Value>, crate::GatewayError> {
|
||||
let Some(context) = report_context.and_then(Value::as_object) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(capability) = context
|
||||
.get(INTERNAL_REPORT_CAPABILITY_FIELD)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if Uuid::parse_str(capability).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !internal_report_late_bound_settlement_fields_are_valid(context)
|
||||
|| !internal_report_windsurf_observation_is_valid(context)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let storage_key = internal_report_capability_storage_key(capability);
|
||||
let Some(serialized) = state
|
||||
.runtime_state
|
||||
.kv_get(&storage_key)
|
||||
.await
|
||||
.map_err(|error| crate::GatewayError::Internal(error.to_string()))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let record: InternalReportCapabilityRecord = serde_json::from_str(&serialized)
|
||||
.map_err(|error| crate::GatewayError::Internal(error.to_string()))?;
|
||||
let protected_context_sha256 = protected_internal_report_context_sha256(context)?;
|
||||
let context_matches = protected_context_sha256 == record.protected_context_sha256
|
||||
|| record.kiro_web_search_context_sha256.as_deref()
|
||||
== Some(protected_context_sha256.as_str());
|
||||
if record.version != INTERNAL_REPORT_CAPABILITY_VERSION
|
||||
|| record.trace_id != trace_id
|
||||
|| internal_report_capability_scope(report_kind).as_deref()
|
||||
!= Some(record.report_scope.as_str())
|
||||
|| !context_matches
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// A report can mutate billing, provider health, file mappings, and video
|
||||
// tasks. Consume its capability so a signed peer cannot replay those side
|
||||
// effects. The second read is atomic: concurrent valid submissions have a
|
||||
// single winner, while invalid submissions above cannot burn the token.
|
||||
let claimed = state
|
||||
.runtime_state
|
||||
.kv_take(&storage_key)
|
||||
.await
|
||||
.map_err(|error| crate::GatewayError::Internal(error.to_string()))?;
|
||||
if claimed.as_deref() != Some(serialized.as_str()) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut resolved = context.clone();
|
||||
resolved.remove(INTERNAL_REPORT_CAPABILITY_FIELD);
|
||||
Ok(Some(Value::Object(resolved)))
|
||||
}
|
||||
|
||||
fn internal_report_capability_scope(report_kind: &str) -> Option<String> {
|
||||
let mut scope = report_kind.trim().to_ascii_lowercase();
|
||||
if scope.is_empty() || scope.len() > 160 {
|
||||
return None;
|
||||
}
|
||||
for suffix in ["_success", "_error", "_failed", "_cancelled", "_finalize"] {
|
||||
if let Some(value) = scope.strip_suffix(suffix) {
|
||||
scope = value.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
for suffix in ["_sync", "_stream"] {
|
||||
if let Some(value) = scope.strip_suffix(suffix) {
|
||||
scope = value.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
(!scope.is_empty()).then_some(scope)
|
||||
}
|
||||
|
||||
fn internal_report_capability_storage_key(capability: &str) -> String {
|
||||
let digest = Sha256::digest(capability.as_bytes());
|
||||
format!("{INTERNAL_REPORT_CAPABILITY_KEY_PREFIX}{digest:x}")
|
||||
}
|
||||
|
||||
fn protected_internal_report_context_sha256(
|
||||
context: &Map<String, Value>,
|
||||
) -> Result<String, crate::GatewayError> {
|
||||
let mut protected = context.clone();
|
||||
protected.remove(INTERNAL_REPORT_CAPABILITY_FIELD);
|
||||
protected.remove(PLAN_USAGE_RESERVATION_TOKEN_FIELD);
|
||||
protected.remove(PLAN_USAGE_RESERVATION_DEFERRED_FIELD);
|
||||
for field in INTERNAL_REPORT_OBSERVATION_FIELDS {
|
||||
protected.remove(*field);
|
||||
}
|
||||
let canonical = canonicalize_internal_report_json(&Value::Object(protected));
|
||||
let encoded = serde_json::to_vec(&canonical)
|
||||
.map_err(|error| crate::GatewayError::Internal(error.to_string()))?;
|
||||
Ok(format!("{:x}", Sha256::digest(encoded)))
|
||||
}
|
||||
|
||||
/// Kiro MCP web-search execution emits a synthetic response that is already in
|
||||
/// the client contract. The executor must therefore disable the planner's Kiro
|
||||
/// envelope conversion before observing that response. Bind that exact, fixed
|
||||
/// transformation when the capability is minted instead of making the affected
|
||||
/// planner fields globally mutable.
|
||||
fn kiro_web_search_internal_report_context_sha256(
|
||||
context: &Map<String, Value>,
|
||||
) -> Result<Option<String>, crate::GatewayError> {
|
||||
let is_kiro_envelope = context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| {
|
||||
value.eq_ignore_ascii_case(aether_provider_transport::kiro::KIRO_ENVELOPE_NAME)
|
||||
});
|
||||
if !is_kiro_envelope {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut synthetic = context.clone();
|
||||
synthetic.insert("has_envelope".to_string(), Value::Bool(false));
|
||||
synthetic.insert("needs_conversion".to_string(), Value::Bool(false));
|
||||
synthetic.remove("envelope_name");
|
||||
synthetic.insert("kiro_web_search_mcp".to_string(), Value::Bool(true));
|
||||
protected_internal_report_context_sha256(&synthetic).map(Some)
|
||||
}
|
||||
|
||||
/// HTTP candidate execution may create a plan-cost reservation only after the
|
||||
/// planner has issued the report capability. Its opaque token is therefore
|
||||
/// late-bound, but the peer cannot use it to select another request or user:
|
||||
/// repository reconciliation also requires the capability-bound request and
|
||||
/// subject identities. Deferred reconciliation is not a normal HTTP report
|
||||
/// outcome and remains forbidden here; allowing it would let a peer strand a
|
||||
/// reservation without submitting a terminal reconciliation.
|
||||
fn internal_report_late_bound_settlement_fields_are_valid(context: &Map<String, Value>) -> bool {
|
||||
match (
|
||||
context.get(PLAN_USAGE_RESERVATION_TOKEN_FIELD),
|
||||
context.get(PLAN_USAGE_RESERVATION_DEFERRED_FIELD),
|
||||
) {
|
||||
(None, None) => true,
|
||||
(Some(Value::String(token)), Some(Value::Bool(false))) => {
|
||||
Uuid::parse_str(token.trim()).is_ok()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn internal_report_windsurf_observation_is_valid(context: &Map<String, Value>) -> bool {
|
||||
match (
|
||||
context.get("windsurf_native_runtime"),
|
||||
context.get("windsurf_language_server_port"),
|
||||
) {
|
||||
(None, None) => true,
|
||||
(Some(Value::Bool(true)), Some(Value::Number(port))) => port
|
||||
.as_u64()
|
||||
.is_some_and(|port| u16::try_from(port).is_ok() && port != 0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_internal_report_json(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Array(values) => Value::Array(
|
||||
values
|
||||
.iter()
|
||||
.map(canonicalize_internal_report_json)
|
||||
.collect(),
|
||||
),
|
||||
Value::Object(object) => {
|
||||
let mut entries = object.iter().collect::<Vec<_>>();
|
||||
entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
|
||||
Value::Object(Map::from_iter(entries.into_iter().map(|(key, value)| {
|
||||
(key.clone(), canonicalize_internal_report_json(value))
|
||||
})))
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_locally_actionable_report_context(
|
||||
state: &AppState,
|
||||
@@ -78,9 +390,14 @@ async fn resolve_locally_actionable_report_context_from_video_task(
|
||||
state: &AppState,
|
||||
context: &Value,
|
||||
) -> Option<Value> {
|
||||
let requested_user_id = requested_report_user_id(context)?;
|
||||
let task = match resolve_video_task_report_lookup(context)? {
|
||||
VideoTaskReportLookup::Lookup(lookup) => {
|
||||
state.data.find_video_task(lookup).await.ok()??
|
||||
let task = state.data.find_video_task(lookup).await.ok()??;
|
||||
if !video_task_matches_requested_user(&task, requested_user_id) {
|
||||
return None;
|
||||
}
|
||||
task
|
||||
}
|
||||
VideoTaskReportLookup::TaskIdOrExternal { task_id, user_id } => {
|
||||
if let Some(task) = state
|
||||
@@ -88,21 +405,47 @@ async fn resolve_locally_actionable_report_context_from_video_task(
|
||||
.find_video_task(VideoTaskLookupKey::Id(task_id))
|
||||
.await
|
||||
.ok()?
|
||||
.filter(|task| video_task_matches_requested_user(task, requested_user_id))
|
||||
{
|
||||
task
|
||||
} else {
|
||||
let user_id = user_id?;
|
||||
state
|
||||
let task = state
|
||||
.data
|
||||
.find_video_task(VideoTaskLookupKey::UserExternal {
|
||||
user_id,
|
||||
external_task_id: task_id,
|
||||
})
|
||||
.await
|
||||
.ok()??
|
||||
.ok()??;
|
||||
if !video_task_matches_requested_user(&task, requested_user_id) {
|
||||
return None;
|
||||
}
|
||||
task
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
build_locally_actionable_report_context_from_video_task(context, &task)
|
||||
}
|
||||
|
||||
fn requested_report_user_id(context: &Value) -> Option<Option<&str>> {
|
||||
match context.get("user_id") {
|
||||
None => Some(None),
|
||||
Some(value) => value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn video_task_matches_requested_user(
|
||||
task: &aether_data_contracts::repository::video_tasks::StoredVideoTask,
|
||||
requested_user_id: Option<&str>,
|
||||
) -> bool {
|
||||
let Some(requested_user_id) = requested_user_id else {
|
||||
return true;
|
||||
};
|
||||
task.user_id.as_deref().map(str::trim) == Some(requested_user_id)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ use crate::task_runtime::{spawn_fire_and_forget, TASK_KEY_USAGE_SYNC_REPORT};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
mod context;
|
||||
pub(crate) use context::{
|
||||
attach_internal_gateway_report_capability, resolve_bound_internal_gateway_report_context,
|
||||
};
|
||||
use context::{report_context_is_locally_actionable, resolve_locally_actionable_report_context};
|
||||
|
||||
use aether_usage_runtime::{
|
||||
@@ -307,6 +310,7 @@ mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
|
||||
use aether_data::repository::gemini_file_mappings::{
|
||||
GeminiFileMappingReadRepository, InMemoryGeminiFileMappingRepository,
|
||||
@@ -328,6 +332,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
attach_internal_gateway_report_capability, resolve_bound_internal_gateway_report_context,
|
||||
resolve_locally_actionable_report_context, submit_stream_report, submit_sync_report,
|
||||
GatewayStreamReportRequest, GatewaySyncReportRequest,
|
||||
};
|
||||
@@ -413,6 +418,42 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
async fn mint_internal_report_capability(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
report_kind: &str,
|
||||
context: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
mint_internal_report_capability_with_headers(
|
||||
state,
|
||||
trace_id,
|
||||
report_kind,
|
||||
&BTreeMap::new(),
|
||||
context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mint_internal_report_capability_with_headers(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
report_kind: &str,
|
||||
provider_request_headers: &BTreeMap<String, String>,
|
||||
context: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
let mut report_context = Some(context);
|
||||
attach_internal_gateway_report_capability(
|
||||
state,
|
||||
trace_id,
|
||||
Some(report_kind),
|
||||
provider_request_headers,
|
||||
&mut report_context,
|
||||
)
|
||||
.await
|
||||
.expect("report capability should mint");
|
||||
report_context.expect("report context should remain present")
|
||||
}
|
||||
|
||||
fn build_video_test_state(
|
||||
video_repository: Arc<InMemoryVideoTaskRepository>,
|
||||
request_candidate_repository: Arc<InMemoryRequestCandidateRepository>,
|
||||
@@ -447,7 +488,8 @@ mod tests {
|
||||
AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(repository),
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -465,6 +507,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn sample_provider_catalog_key(key_id: &str, provider_id: &str) -> StoredProviderCatalogKey {
|
||||
let credential_state = AppState::new()
|
||||
.expect("credential state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::disabled()
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
let encrypted_api_key = credential_state
|
||||
.seal_provider_catalog_key_api_key(provider_id, key_id, "sk-codex-test")
|
||||
.expect("api key should encrypt");
|
||||
StoredProviderCatalogKey::new(
|
||||
key_id.to_string(),
|
||||
provider_id.to_string(),
|
||||
@@ -476,7 +527,7 @@ mod tests {
|
||||
.expect("key should build")
|
||||
.with_transport_fields(
|
||||
Some(json!(["openai:responses"])),
|
||||
"sk-codex-test".to_string(),
|
||||
encrypted_api_key,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -530,6 +581,7 @@ mod tests {
|
||||
repository: &InMemoryVideoTaskRepository,
|
||||
id: &str,
|
||||
short_id: Option<&str>,
|
||||
external_task_id: &str,
|
||||
request_id: &str,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
@@ -548,7 +600,7 @@ mod tests {
|
||||
api_key_id: Some(api_key_id.to_string()),
|
||||
username: Some("video-user".to_string()),
|
||||
api_key_name: Some("video-key".to_string()),
|
||||
external_task_id: Some("ext-video-task-reporting-123".to_string()),
|
||||
external_task_id: Some(external_task_id.to_string()),
|
||||
provider_id: Some(provider_id.to_string()),
|
||||
endpoint_id: Some(endpoint_id.to_string()),
|
||||
key_id: Some(key_id.to_string()),
|
||||
@@ -598,6 +650,441 @@ mod tests {
|
||||
assert!(resolved.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_report_capability_validates_once_without_candidate_persistence() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = build_test_state(repository);
|
||||
let mut context = mint_internal_report_capability(
|
||||
&state,
|
||||
"trace-capability-valid-123",
|
||||
"openai_chat_sync_success",
|
||||
json!({
|
||||
"request_id": "req-capability-valid-123",
|
||||
"candidate_id": "cand-capability-valid-123",
|
||||
"user_id": "user-capability-valid-123",
|
||||
"provider_id": "provider-reporting-tests-123",
|
||||
"key_id": "key-reporting-tests-123",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
context
|
||||
.as_object_mut()
|
||||
.expect("context should be an object")
|
||||
.extend([
|
||||
(
|
||||
"provider_response_headers".to_string(),
|
||||
json!({"x-ratelimit-limit": "100"}),
|
||||
),
|
||||
("upstream_response".to_string(), json!({"id": "resp-123"})),
|
||||
("error_flow".to_string(), json!({"stage": "upstream"})),
|
||||
(
|
||||
"client_response_headers".to_string(),
|
||||
json!({"content-type": "application/json"}),
|
||||
),
|
||||
]);
|
||||
|
||||
let resolved = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-valid-123",
|
||||
"openai_chat_sync_error",
|
||||
Some(&context),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed")
|
||||
.expect("capability should validate");
|
||||
assert!(resolved.get("_aether_internal_report_capability").is_none());
|
||||
assert_eq!(resolved["upstream_response"]["id"], "resp-123");
|
||||
|
||||
let replay = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-valid-123",
|
||||
"openai_chat_sync_success",
|
||||
Some(&context),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed");
|
||||
assert!(replay.is_none(), "a consumed capability must not replay");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_report_capability_rejects_missing_unknown_trace_and_scope_mismatches() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = build_test_state(repository);
|
||||
let protected = json!({
|
||||
"request_id": "req-capability-reject-123",
|
||||
"candidate_id": "cand-capability-reject-123",
|
||||
"user_id": "user-capability-reject-123",
|
||||
"provider_id": "provider-capability-reject-123",
|
||||
"key_id": "key-capability-reject-123",
|
||||
});
|
||||
let minted = mint_internal_report_capability(
|
||||
&state,
|
||||
"trace-capability-reject-123",
|
||||
"openai_chat_stream_success",
|
||||
protected.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let unknown = {
|
||||
let mut value = minted.clone();
|
||||
value["_aether_internal_report_capability"] =
|
||||
json!(uuid::Uuid::new_v4().simple().to_string());
|
||||
value
|
||||
};
|
||||
for (trace_id, report_kind, context) in [
|
||||
(
|
||||
"trace-capability-reject-123",
|
||||
"openai_chat_stream_success",
|
||||
protected.clone(),
|
||||
),
|
||||
(
|
||||
"trace-capability-reject-123",
|
||||
"openai_chat_stream_success",
|
||||
unknown,
|
||||
),
|
||||
(
|
||||
"trace-capability-attacker-123",
|
||||
"openai_chat_stream_success",
|
||||
minted.clone(),
|
||||
),
|
||||
(
|
||||
"trace-capability-reject-123",
|
||||
"gemini_files_store_mapping",
|
||||
minted.clone(),
|
||||
),
|
||||
(
|
||||
"trace-capability-reject-123",
|
||||
"openai_video_create_sync_finalize",
|
||||
minted.clone(),
|
||||
),
|
||||
] {
|
||||
let rejected = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
trace_id,
|
||||
report_kind,
|
||||
Some(&context),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed");
|
||||
assert!(
|
||||
rejected.is_none(),
|
||||
"invalid capability use must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
let valid = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-reject-123",
|
||||
"openai_chat_sync_finalize",
|
||||
Some(&minted),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed");
|
||||
assert!(
|
||||
valid.is_some(),
|
||||
"invalid attempts must not consume the capability"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_report_capability_rejects_every_protected_identity_mutation() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = build_test_state(repository);
|
||||
let minted = mint_internal_report_capability(
|
||||
&state,
|
||||
"trace-capability-fields-123",
|
||||
"openai_video_create_sync_finalize",
|
||||
json!({
|
||||
"request_id": "req-capability-fields-123",
|
||||
"candidate_id": "cand-capability-fields-123",
|
||||
"user_id": "user-capability-fields-123",
|
||||
"api_key_id": "api-key-capability-fields-123",
|
||||
"provider_id": "provider-capability-fields-123",
|
||||
"endpoint_id": "endpoint-capability-fields-123",
|
||||
"key_id": "key-capability-fields-123",
|
||||
"file_key_id": "file-key-capability-fields-123",
|
||||
"task_id": "task-capability-fields-123",
|
||||
"local_task_id": "local-task-capability-fields-123",
|
||||
"local_short_id": "short-capability-fields-123",
|
||||
"file_name": "files/capability-fields-123",
|
||||
"client_api_format": "openai:video",
|
||||
"upstream_url": "https://provider.example/v1/videos",
|
||||
"has_envelope": false,
|
||||
"needs_conversion": false,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
for field in [
|
||||
"user_id",
|
||||
"api_key_id",
|
||||
"provider_id",
|
||||
"endpoint_id",
|
||||
"key_id",
|
||||
"file_key_id",
|
||||
"task_id",
|
||||
"local_task_id",
|
||||
"local_short_id",
|
||||
"file_name",
|
||||
"client_api_format",
|
||||
"upstream_url",
|
||||
"has_envelope",
|
||||
"needs_conversion",
|
||||
] {
|
||||
let mut forged = minted.clone();
|
||||
forged[field] = json!(format!("attacker-{field}"));
|
||||
let resolved = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-fields-123",
|
||||
"openai_video_create_sync_finalize",
|
||||
Some(&forged),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed");
|
||||
assert!(resolved.is_none(), "mutating {field} must be rejected");
|
||||
}
|
||||
|
||||
for report_kind in [
|
||||
"openai_video_delete_sync_finalize",
|
||||
"openai_video_cancel_sync_finalize",
|
||||
"gemini_video_create_sync_finalize",
|
||||
] {
|
||||
let resolved = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-fields-123",
|
||||
report_kind,
|
||||
Some(&minted),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed");
|
||||
assert!(resolved.is_none(), "cross-operation use must be rejected");
|
||||
}
|
||||
|
||||
let valid = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-fields-123",
|
||||
"openai_video_create_sync_error",
|
||||
Some(&minted),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed");
|
||||
assert!(valid.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_report_capability_allows_only_the_bound_kiro_web_search_transform() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = build_test_state(repository);
|
||||
let minted = mint_internal_report_capability(
|
||||
&state,
|
||||
"trace-capability-kiro-search-123",
|
||||
"openai_chat_stream_success",
|
||||
json!({
|
||||
"request_id": "req-capability-kiro-search-123",
|
||||
"candidate_id": "cand-capability-kiro-search-123",
|
||||
"user_id": "user-capability-kiro-search-123",
|
||||
"provider_id": "provider-capability-kiro-search-123",
|
||||
"key_id": "key-capability-kiro-search-123",
|
||||
"upstream_url": "https://kiro.example/generateAssistantResponse",
|
||||
"has_envelope": true,
|
||||
"needs_conversion": true,
|
||||
"envelope_name": aether_provider_transport::kiro::KIRO_ENVELOPE_NAME,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut forged_target = minted.clone();
|
||||
forged_target["upstream_url"] = json!("https://attacker.example/forged");
|
||||
forged_target["has_envelope"] = json!(false);
|
||||
forged_target["needs_conversion"] = json!(false);
|
||||
forged_target["kiro_web_search_mcp"] = json!(true);
|
||||
forged_target
|
||||
.as_object_mut()
|
||||
.expect("context should be an object")
|
||||
.remove("envelope_name");
|
||||
let rejected = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-kiro-search-123",
|
||||
"openai_chat_stream_success",
|
||||
Some(&forged_target),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed");
|
||||
assert!(rejected.is_none(), "the upstream target must remain bound");
|
||||
|
||||
let mut synthetic = minted;
|
||||
synthetic["has_envelope"] = json!(false);
|
||||
synthetic["needs_conversion"] = json!(false);
|
||||
synthetic["kiro_web_search_mcp"] = json!(true);
|
||||
synthetic
|
||||
.as_object_mut()
|
||||
.expect("context should be an object")
|
||||
.remove("envelope_name");
|
||||
let resolved = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-kiro-search-123",
|
||||
"openai_chat_stream_success",
|
||||
Some(&synthetic),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed")
|
||||
.expect("the pre-bound Kiro web-search transform should validate");
|
||||
assert_eq!(resolved["kiro_web_search_mcp"], json!(true));
|
||||
assert_eq!(
|
||||
resolved["upstream_url"],
|
||||
json!("https://kiro.example/generateAssistantResponse")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_report_capability_binds_final_provider_request_headers() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = build_test_state(repository);
|
||||
let headers = BTreeMap::from([
|
||||
(
|
||||
"authorization".to_string(),
|
||||
"Bearer final-token".to_string(),
|
||||
),
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
]);
|
||||
let minted = mint_internal_report_capability_with_headers(
|
||||
&state,
|
||||
"trace-capability-headers-123",
|
||||
"openai_video_create_sync_success",
|
||||
&headers,
|
||||
json!({
|
||||
"request_id": "req-capability-headers-123",
|
||||
"provider_request_headers": {"authorization": "Bearer stale-token"},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(minted["provider_request_headers"], json!(headers));
|
||||
|
||||
let mut forged = minted.clone();
|
||||
forged["provider_request_headers"]["authorization"] = json!("Bearer attacker-token");
|
||||
let rejected = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-headers-123",
|
||||
"openai_video_create_sync_success",
|
||||
Some(&forged),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed");
|
||||
assert!(
|
||||
rejected.is_none(),
|
||||
"final request headers must remain bound"
|
||||
);
|
||||
|
||||
let resolved = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-headers-123",
|
||||
"openai_video_create_sync_success",
|
||||
Some(&minted),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed")
|
||||
.expect("the authoritative final headers should validate");
|
||||
assert_eq!(resolved["provider_request_headers"], json!(headers));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_report_capability_validates_windsurf_native_observation_shape() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = build_test_state(repository);
|
||||
let minted = mint_internal_report_capability(
|
||||
&state,
|
||||
"trace-capability-windsurf-123",
|
||||
"openai_responses_stream_success",
|
||||
json!({"request_id": "req-capability-windsurf-123"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
for (native_runtime, port) in [
|
||||
(json!(false), json!(42_137)),
|
||||
(json!(true), json!(0)),
|
||||
(json!(true), json!(65_536)),
|
||||
(json!(true), json!("42137")),
|
||||
] {
|
||||
let mut invalid = minted.clone();
|
||||
invalid["windsurf_native_runtime"] = native_runtime;
|
||||
invalid["windsurf_language_server_port"] = port;
|
||||
let rejected = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-windsurf-123",
|
||||
"openai_responses_stream_success",
|
||||
Some(&invalid),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed");
|
||||
assert!(rejected.is_none(), "invalid Windsurf metadata must fail");
|
||||
}
|
||||
|
||||
let mut valid = minted;
|
||||
valid["windsurf_native_runtime"] = json!(true);
|
||||
valid["windsurf_language_server_port"] = json!(42_137);
|
||||
let resolved = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-windsurf-123",
|
||||
"openai_responses_stream_success",
|
||||
Some(&valid),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed")
|
||||
.expect("valid Windsurf native metadata should pass");
|
||||
assert_eq!(resolved["windsurf_native_runtime"], json!(true));
|
||||
assert_eq!(resolved["windsurf_language_server_port"], json!(42_137));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_report_capability_accepts_only_non_deferred_late_bound_reservations() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state = build_test_state(repository);
|
||||
let minted = mint_internal_report_capability(
|
||||
&state,
|
||||
"trace-capability-reservation-123",
|
||||
"openai_chat_sync_success",
|
||||
json!({
|
||||
"request_id": "req-capability-reservation-123",
|
||||
"candidate_id": "cand-capability-reservation-123",
|
||||
"user_id": "user-capability-reservation-123",
|
||||
"provider_id": "provider-capability-reservation-123",
|
||||
"key_id": "key-capability-reservation-123",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut deferred = minted.clone();
|
||||
deferred["plan_usage_reservation_token"] = json!(uuid::Uuid::new_v4().to_string());
|
||||
deferred["plan_usage_reservation_deferred"] = json!(true);
|
||||
let rejected = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-reservation-123",
|
||||
"openai_chat_sync_success",
|
||||
Some(&deferred),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed");
|
||||
assert!(
|
||||
rejected.is_none(),
|
||||
"a peer must not defer terminal reservation reconciliation"
|
||||
);
|
||||
|
||||
let reservation_token = uuid::Uuid::new_v4().to_string();
|
||||
let mut terminal = minted;
|
||||
terminal["plan_usage_reservation_token"] = json!(reservation_token);
|
||||
terminal["plan_usage_reservation_deferred"] = json!(false);
|
||||
let resolved = resolve_bound_internal_gateway_report_context(
|
||||
&state,
|
||||
"trace-capability-reservation-123",
|
||||
"openai_chat_sync_success",
|
||||
Some(&terminal),
|
||||
)
|
||||
.await
|
||||
.expect("capability lookup should succeed")
|
||||
.expect("a server-issued terminal reservation token should validate");
|
||||
assert_eq!(resolved["plan_usage_reservation_deferred"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_sync_report_handles_request_id_only_context_locally_when_unique_candidate_exists(
|
||||
) {
|
||||
@@ -821,10 +1308,7 @@ mod tests {
|
||||
stored[0].error_type.as_deref(),
|
||||
Some("stream_missing_terminal_event")
|
||||
);
|
||||
assert_eq!(
|
||||
stored[0].error_message.as_deref(),
|
||||
Some("execution runtime stream ended before provider terminal event")
|
||||
);
|
||||
assert!(stored[0].error_message.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1365,7 +1849,7 @@ mod tests {
|
||||
"req-gemini-files-delete-123",
|
||||
),
|
||||
]));
|
||||
let gemini_file_mapping_repository = Arc::new(InMemoryGeminiFileMappingRepository::seed([
|
||||
let mut mapping =
|
||||
aether_data::repository::gemini_file_mappings::StoredGeminiFileMapping::new(
|
||||
"mapping-gemini-files-delete-123".to_string(),
|
||||
"files/delete-me".to_string(),
|
||||
@@ -1373,8 +1857,10 @@ mod tests {
|
||||
1_700_000_000,
|
||||
1_700_172_800,
|
||||
)
|
||||
.expect("gemini file mapping should build"),
|
||||
]));
|
||||
.expect("gemini file mapping should build");
|
||||
mapping.user_id = Some("user-reporting-tests-123".to_string());
|
||||
let gemini_file_mapping_repository =
|
||||
Arc::new(InMemoryGeminiFileMappingRepository::seed([mapping]));
|
||||
let state = build_gemini_file_mapping_test_state(
|
||||
Arc::clone(&request_candidate_repository),
|
||||
Arc::clone(&gemini_file_mapping_repository),
|
||||
@@ -1392,6 +1878,7 @@ mod tests {
|
||||
"provider_id": "provider-reporting-tests-123",
|
||||
"endpoint_id": "endpoint-reporting-tests-123",
|
||||
"key_id": "key-reporting-tests-123",
|
||||
"user_id": "user-reporting-tests-123",
|
||||
"file_name": "delete-me",
|
||||
})),
|
||||
status_code: 204,
|
||||
@@ -1461,6 +1948,7 @@ mod tests {
|
||||
&video_repository,
|
||||
"task-openai-video-reporting-123",
|
||||
None,
|
||||
"ext-video-task-reporting-123",
|
||||
"req-openai-video-reporting-123",
|
||||
"user-openai-video-reporting-123",
|
||||
"api-key-openai-video-reporting-123",
|
||||
@@ -1522,6 +2010,7 @@ mod tests {
|
||||
&video_repository,
|
||||
"task-gemini-video-reporting-123",
|
||||
Some("short-gemini-video-reporting-123"),
|
||||
"ext-video-task-reporting-123",
|
||||
"req-gemini-video-reporting-123",
|
||||
"user-gemini-video-reporting-123",
|
||||
"api-key-gemini-video-reporting-123",
|
||||
@@ -1582,6 +2071,7 @@ mod tests {
|
||||
&video_repository,
|
||||
"task-openai-video-task-id-123",
|
||||
None,
|
||||
"ext-video-task-reporting-123",
|
||||
"req-openai-video-task-id-123",
|
||||
"user-openai-video-task-id-123",
|
||||
"api-key-openai-video-task-id-123",
|
||||
@@ -1642,6 +2132,7 @@ mod tests {
|
||||
&video_repository,
|
||||
"task-gemini-video-external-id-123",
|
||||
Some("short-gemini-video-external-id-123"),
|
||||
"models/veo-3/operations/ext-gemini-video-123",
|
||||
"req-gemini-video-external-id-123",
|
||||
"user-gemini-video-external-id-123",
|
||||
"api-key-gemini-video-external-id-123",
|
||||
@@ -1652,48 +2143,6 @@ mod tests {
|
||||
"gemini:video",
|
||||
)
|
||||
.await;
|
||||
video_repository
|
||||
.upsert(UpsertVideoTask {
|
||||
id: "task-gemini-video-external-id-123".to_string(),
|
||||
short_id: Some("short-gemini-video-external-id-123".to_string()),
|
||||
request_id: "req-gemini-video-external-id-123".to_string(),
|
||||
user_id: Some("user-gemini-video-external-id-123".to_string()),
|
||||
api_key_id: Some("api-key-gemini-video-external-id-123".to_string()),
|
||||
username: Some("video-user".to_string()),
|
||||
api_key_name: Some("video-key".to_string()),
|
||||
external_task_id: Some("models/veo-3/operations/ext-gemini-video-123".to_string()),
|
||||
provider_id: Some("provider-gemini-video-external-id-123".to_string()),
|
||||
endpoint_id: Some("endpoint-gemini-video-external-id-123".to_string()),
|
||||
key_id: Some("key-gemini-video-external-id-123".to_string()),
|
||||
client_api_format: Some("gemini:video".to_string()),
|
||||
provider_api_format: Some("gemini:video".to_string()),
|
||||
format_converted: false,
|
||||
model: Some("video-model".to_string()),
|
||||
prompt: Some("video prompt".to_string()),
|
||||
original_request_body: Some(json!({"prompt": "video prompt"})),
|
||||
duration_seconds: Some(4),
|
||||
resolution: Some("720p".to_string()),
|
||||
aspect_ratio: Some("16:9".to_string()),
|
||||
size: Some("1280x720".to_string()),
|
||||
status: VideoTaskStatus::Submitted,
|
||||
progress_percent: 0,
|
||||
progress_message: None,
|
||||
retry_count: 0,
|
||||
poll_interval_seconds: 10,
|
||||
next_poll_at_unix_secs: Some(1_700_000_010),
|
||||
poll_count: 0,
|
||||
max_poll_count: 360,
|
||||
created_at_unix_ms: 1_700_000_000,
|
||||
submitted_at_unix_secs: Some(1_700_000_000),
|
||||
completed_at_unix_secs: None,
|
||||
updated_at_unix_secs: 1_700_000_000,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
video_url: None,
|
||||
request_metadata: None,
|
||||
})
|
||||
.await
|
||||
.expect("video task should update external id");
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let state =
|
||||
build_video_test_state(video_repository, Arc::clone(&request_candidate_repository));
|
||||
@@ -1737,4 +2186,67 @@ mod tests {
|
||||
Some("key-gemini-video-external-id-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_context_task_id_collision_stays_bound_to_requested_user() {
|
||||
let video_repository = Arc::new(InMemoryVideoTaskRepository::default());
|
||||
seed_video_task(
|
||||
&video_repository,
|
||||
"shared-task-id",
|
||||
Some("victim-short-id"),
|
||||
"victim-external-id",
|
||||
"req-video-victim",
|
||||
"user-video-victim",
|
||||
"api-key-video-victim",
|
||||
"provider-video-victim",
|
||||
"endpoint-video-victim",
|
||||
"key-video-victim",
|
||||
"gemini:video",
|
||||
"gemini:video",
|
||||
)
|
||||
.await;
|
||||
seed_video_task(
|
||||
&video_repository,
|
||||
"owner-local-id",
|
||||
Some("owner-short-id"),
|
||||
"shared-task-id",
|
||||
"req-video-owner",
|
||||
"user-video-owner",
|
||||
"api-key-video-owner",
|
||||
"provider-video-owner",
|
||||
"endpoint-video-owner",
|
||||
"key-video-owner",
|
||||
"gemini:video",
|
||||
"gemini:video",
|
||||
)
|
||||
.await;
|
||||
let state = build_video_test_state(
|
||||
video_repository,
|
||||
Arc::new(InMemoryRequestCandidateRepository::default()),
|
||||
);
|
||||
|
||||
let resolved = resolve_locally_actionable_report_context(
|
||||
&state,
|
||||
Some(&json!({
|
||||
"task_id": "shared-task-id",
|
||||
"user_id": "user-video-owner",
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("the owner's external task id should resolve");
|
||||
|
||||
assert_eq!(resolved["request_id"], "req-video-owner");
|
||||
assert_eq!(resolved["provider_id"], "provider-video-owner");
|
||||
assert_eq!(resolved["user_id"], "user-video-owner");
|
||||
|
||||
let foreign_local_id = resolve_locally_actionable_report_context(
|
||||
&state,
|
||||
Some(&json!({
|
||||
"local_task_id": "shared-task-id",
|
||||
"user_id": "user-video-owner",
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert!(foreign_local_id.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user