@@ -212,14 +212,30 @@ impl EcContext {
212212 . client_ip
213213 . map ( generation:: normalize_ip) ;
214214
215- // Build consent context from request-local cookies, headers, and geo.
215+ // Resolve the consent KV store for persistence, keyed by the existing EC
216+ // id. Resolution is deliberately best-effort: this runs on every request
217+ // that builds an EC context, including routes that do not depend on
218+ // consent, so a configured-but-unresolvable store must not fail here.
219+ // Fail-closed enforcement for a missing consent store lives at the
220+ // consent-dependent routes (`consent::resolve_consent_kv`), which keeps a
221+ // misconfiguration from breaking unrelated routes.
222+ let consent_kv = settings
223+ . consent
224+ . consent_store
225+ . as_deref ( )
226+ . and_then ( |store_id| services. kv_handle_named ( store_id) ) ;
227+
228+ // Build consent context from request-local cookies, headers, and geo,
229+ // falling back to (and persisting) KV-backed consent for this EC id. A
230+ // request without an EC id yet cannot key persistence, so it stays
231+ // request-local until the EC cookie is present on a later request.
216232 let consent = consent_mod:: build_consent_context ( & ConsentPipelineInput {
217233 jar : parsed. jar . as_ref ( ) ,
218234 req,
219235 config : & settings. consent ,
220236 geo : geo_info,
221- ec_id : None ,
222- kv_store : None ,
237+ ec_id : ec_value . as_deref ( ) ,
238+ kv_store : consent_kv ,
223239 } ) ;
224240
225241 log:: info!(
@@ -678,4 +694,175 @@ mod tests {
678694 "should return cookie value for revocation even when header is present"
679695 ) ;
680696 }
697+
698+ // ---------------------------------------------------------------------
699+ // Consent KV persistence wiring
700+ // ---------------------------------------------------------------------
701+
702+ /// In-memory [`crate::platform::PlatformKvStore`] double for the consent
703+ /// persistence wiring tests.
704+ struct InMemoryKvStore {
705+ entries : std:: sync:: Mutex < std:: collections:: HashMap < String , bytes:: Bytes > > ,
706+ }
707+
708+ impl InMemoryKvStore {
709+ fn new ( ) -> Self {
710+ Self {
711+ entries : std:: sync:: Mutex :: new ( std:: collections:: HashMap :: new ( ) ) ,
712+ }
713+ }
714+ }
715+
716+ #[ async_trait:: async_trait( ?Send ) ]
717+ impl crate :: platform:: PlatformKvStore for InMemoryKvStore {
718+ async fn get_bytes (
719+ & self ,
720+ key : & str ,
721+ ) -> Result < Option < bytes:: Bytes > , crate :: platform:: KvError > {
722+ Ok ( self
723+ . entries
724+ . lock ( )
725+ . expect ( "should lock entries" )
726+ . get ( key)
727+ . cloned ( ) )
728+ }
729+
730+ async fn put_bytes (
731+ & self ,
732+ key : & str ,
733+ value : bytes:: Bytes ,
734+ ) -> Result < ( ) , crate :: platform:: KvError > {
735+ self . entries
736+ . lock ( )
737+ . expect ( "should lock entries" )
738+ . insert ( key. to_owned ( ) , value) ;
739+ Ok ( ( ) )
740+ }
741+
742+ async fn put_bytes_with_ttl (
743+ & self ,
744+ key : & str ,
745+ value : bytes:: Bytes ,
746+ _ttl : std:: time:: Duration ,
747+ ) -> Result < ( ) , crate :: platform:: KvError > {
748+ self . put_bytes ( key, value) . await
749+ }
750+
751+ async fn delete ( & self , key : & str ) -> Result < ( ) , crate :: platform:: KvError > {
752+ self . entries
753+ . lock ( )
754+ . expect ( "should lock entries" )
755+ . remove ( key) ;
756+ Ok ( ( ) )
757+ }
758+
759+ async fn list_keys_page (
760+ & self ,
761+ _prefix : & str ,
762+ _cursor : Option < & str > ,
763+ _limit : usize ,
764+ ) -> Result < edgezero_core:: key_value_store:: KvPage , crate :: platform:: KvError > {
765+ Ok ( edgezero_core:: key_value_store:: KvPage :: default ( ) )
766+ }
767+ }
768+
769+ /// Settings whose consent pipeline persists to the `consent_store` id.
770+ fn settings_with_consent_store ( ) -> Settings {
771+ let mut settings = create_test_settings ( ) ;
772+ settings. consent . consent_store = Some ( "consent_store" . to_owned ( ) ) ;
773+ settings
774+ }
775+
776+ /// Build services whose KV registry holds a distinct default store and a
777+ /// named `consent_store`, returning both handles so tests can assert which
778+ /// store was written.
779+ fn services_with_consent_kv ( ) -> (
780+ RuntimeServices ,
781+ crate :: platform:: KvHandle ,
782+ crate :: platform:: KvHandle ,
783+ ) {
784+ let default_handle =
785+ crate :: platform:: KvHandle :: new ( std:: sync:: Arc :: new ( InMemoryKvStore :: new ( ) ) ) ;
786+ let consent_handle =
787+ crate :: platform:: KvHandle :: new ( std:: sync:: Arc :: new ( InMemoryKvStore :: new ( ) ) ) ;
788+ let by_id = std:: collections:: BTreeMap :: from ( [
789+ ( "trusted_server_kv" . to_owned ( ) , default_handle. clone ( ) ) ,
790+ ( "consent_store" . to_owned ( ) , consent_handle. clone ( ) ) ,
791+ ] ) ;
792+ let registry = edgezero_core:: store_registry:: StoreRegistry :: from_parts (
793+ by_id,
794+ "trusted_server_kv" . to_owned ( ) ,
795+ )
796+ . expect ( "should build kv registry" ) ;
797+ let services = crate :: platform:: test_support:: noop_services_with_kv_registry ( registry) ;
798+ ( services, default_handle, consent_handle)
799+ }
800+
801+ #[ test]
802+ fn read_from_request_persists_consent_to_the_named_consent_store ( ) {
803+ let settings = settings_with_consent_store ( ) ;
804+ let ( services, default_handle, consent_handle) = services_with_consent_kv ( ) ;
805+ let ec_id = valid_ec_id ( "c" , "Persis" ) ;
806+ let cookie = format ! ( "ts-ec={ec_id}; us_privacy=1YNN" ) ;
807+ let req = create_test_request ( & [ ( "cookie" , & cookie) ] ) ;
808+
809+ EcContext :: read_from_request ( & settings, & req, & services) . expect ( "should read EC context" ) ;
810+
811+ let persisted = crate :: consent:: kv:: load_consent_from_kv ( & consent_handle, & ec_id)
812+ . expect ( "should persist cookie-sourced consent under the EC id" ) ;
813+ assert_eq ! (
814+ persisted. raw_us_privacy. as_deref( ) ,
815+ Some ( "1YNN" ) ,
816+ "should persist the cookie-sourced consent signal"
817+ ) ;
818+ assert ! (
819+ crate :: consent:: kv:: load_consent_from_kv( & default_handle, & ec_id) . is_none( ) ,
820+ "should resolve the named consent store, never the default KV store"
821+ ) ;
822+ }
823+
824+ #[ test]
825+ fn read_from_request_falls_back_to_persisted_consent_when_request_has_no_signals ( ) {
826+ let settings = settings_with_consent_store ( ) ;
827+ let ( services, _default_handle, _consent_handle) = services_with_consent_kv ( ) ;
828+ let ec_id = valid_ec_id ( "d" , "Fallbk" ) ;
829+
830+ // First request carries a consent cookie — persisted under the EC id.
831+ let seeding_cookie = format ! ( "ts-ec={ec_id}; us_privacy=1YNN" ) ;
832+ let seeding_req = create_test_request ( & [ ( "cookie" , & seeding_cookie) ] ) ;
833+ EcContext :: read_from_request ( & settings, & seeding_req, & services)
834+ . expect ( "should read EC context for the seeding request" ) ;
835+
836+ // Second request carries only the EC cookie — no consent signals.
837+ let bare_cookie = format ! ( "ts-ec={ec_id}" ) ;
838+ let bare_req = create_test_request ( & [ ( "cookie" , & bare_cookie) ] ) ;
839+ let ec = EcContext :: read_from_request ( & settings, & bare_req, & services)
840+ . expect ( "should read EC context for the bare request" ) ;
841+
842+ assert_eq ! (
843+ ec. consent( ) . raw_us_privacy. as_deref( ) ,
844+ Some ( "1YNN" ) ,
845+ "should fall back to consent persisted under the EC id"
846+ ) ;
847+ }
848+
849+ #[ test]
850+ fn read_from_request_succeeds_when_consent_store_is_configured_but_unresolved ( ) {
851+ // A configured-but-unresolvable consent store must not fail the EC
852+ // context: fail-closed lives at the consent-dependent routes, so a
853+ // misconfiguration must not break routes that never read consent.
854+ let settings = settings_with_consent_store ( ) ;
855+ let ec_id = valid_ec_id ( "e" , "NoRegs" ) ;
856+ let cookie = format ! ( "ts-ec={ec_id}; us_privacy=1YNN" ) ;
857+ let req = create_test_request ( & [ ( "cookie" , & cookie) ] ) ;
858+
859+ let ec = EcContext :: read_from_request ( & settings, & req, & noop_services ( ) )
860+ . expect ( "should read EC context without a KV registry" ) ;
861+
862+ assert_eq ! (
863+ ec. consent( ) . raw_us_privacy. as_deref( ) ,
864+ Some ( "1YNN" ) ,
865+ "should still build request-local consent with persistence disabled"
866+ ) ;
867+ }
681868}
0 commit comments