okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
3b0866f
main
1// OAuth callback races, state validation, and fixed-port contention are timing-sensitive and cannot be scripted as an E2E model turn. 2import albedo/daemon/http_api 3import albedo/harness/oauth 4import gleam/dict 5import gleam/dynamic 6import gleam/dynamic/decode 7import gleam/erlang/process 8import gleam/json 9import gleam/list 10import gleam/option .{ Some } 11import gleam/result 12import gleam/string 13import gleam/uri 14 15fn fake ( callback : oauth . Callback ) -> oauth . Login { 16oauth . Login ( 17"fake" , 18"add fake account" , 19"" , 20oauth . Model , 21"fake-accounts" , 22callback , 23fn ( grant ) { 24"https://auth.example/authorize?" 25<> uri . query_to_string ([ 26#( "redirect_uri" , grant . redirect ), 27#( "state" , grant . state ), 28]) 29}, 30fn ( _grant , code , progress ) { 31progress ( "provisioning " <> code ) 32case code { 33"bad" -> Error ( "exchange rejected" ) 34_ -> 35Ok ( 36dict . from_list ([ 37#( "type" , dynamic . string ( "oauth" )), 38#( "access" , dynamic . string ( "access-" <> code )), 39#( "email" , dynamic . string ( code <> "@example.com" )), 40]), 41) 42} 43}, 44fn ( credential ) { 45let = 46decode . run ( credential , decode . at ([ "email" ], decode . string )) 47|> result . unwrap ( "" ) 48let selected = 49decode . run ( credential , decode . at ([ "selected" ], decode . bool )) 50|> result . unwrap ( False ) 51oauth . Account ( , , "fake account" , selected ) 52}, 53) 54} 55 56fn ephemeral () -> oauth . Login { 57fake ( oauth . Callback ( "127.0.0.1" , 0 , "/cb" , False )) 58} 59 60fn query ( url : String ) -> List (#( String , String )) { 61let assert Ok ( parsed ) = uri . parse ( url ) 62let assert Ok ( pairs ) = uri . parse_query ( option . unwrap ( parsed . query , "" )) 63pairs 64} 65 66fn start ( home : String , login : oauth . Login ) -> #( String , String ) { 67let id = identity () 68let assert Ok (#( True , flow )) = 69oauth . start_identified ( 70home , 71id , 72"fake" , 73Some ( login ), 74"{\"provider\":\"fake\"}" , 75) 76#( id , flow ) 77} 78 79fn field ( flow : String , key : String ) -> String { 80let assert Ok ( value ) = 81json . parse ( flow , decode . field ( key , decode . string , decode . success )) 82value 83} 84 85fn settle ( 86home : String , 87id : String , 88login : oauth . Login , 89attempts : Int , 90) -> String { 91let assert Ok ( flow ) = oauth . get_identified ( home , id , [ login ]) 92case field ( flow , "state" ), attempts { 93"waiting" , n |"exchanging" , n if n > 0 -> { 94process . sleep ( 20 ) 95settle ( home , id , login , n - 1 ) 96} 97_ , _ -> flow 98} 99} 100 101pub fn a_pasted_redirect_races_the_callback_test () -> Nil { 102let #( root , _ , home ) = fixture () 103let login = ephemeral () 104let #( id , flow ) = start ( home , login ) 105let assert Ok ( state ) = list . key_find ( query ( field ( flow , "url" )), "state" ) 106let assert Ok ( _ ) = 107oauth . input_identified ( 108home , 109id , 110http_api . etag ( flow ), 111"http://127.0.0.1:9/cb?code=cy&state=" <> state , 112[ login ], 113) 114let complete = settle ( home , id , login , 100 ) 115assert field ( complete , "state" ) == "complete" 116let assert [ account ] = oauth . accounts ( home , login ) 117assert account . label == "cy@example.com" 118// Terminal outcome remains durable after the flow process has stopped. 119let assert Ok ( retained ) = oauth . get_identified ( home , id , [ login ]) 120assert retained == complete 121let assert Ok ( snapshot ) = oauth . auth_snapshot ( home , [ login ]) 122let assert Ok ([ provider_kind ]) = 123json . parse ( 124snapshot , 125decode . at ([ "providers" ], decode . list ( decode . at ([ "kind" ], decode . string ))), 126) 127assert provider_kind == "model" 128let assert Ok ([ account_kind ]) = 129json . parse ( 130snapshot , 131decode . at ([ "accounts" ], decode . list ( decode . at ([ "kind" ], decode . string ))), 132) 133assert account_kind == "model" 134cleanup ( root ) 135} 136 137pub fn a_pasted_code_for_another_attempt_is_rejected_test () -> Nil { 138let #( root , _ , home ) = fixture () 139let login = ephemeral () 140let #( id , flow ) = start ( home , login ) 141let assert Ok ( _ ) = 142oauth . input_identified ( home , id , http_api . etag ( flow ), "cy#not-this-state" , [ 143login , 144]) 145let failed = settle ( home , id , login , 100 ) 146assert field ( failed , "state" ) == "failed" 147assert field ( failed , "failure" ) == "Provider authorization failed." 148assert oauth . accounts ( home , login ) == [] 149cleanup ( root ) 150} 151 152pub fn a_busy_fixed_port_fails_instead_of_moving_test () -> Nil { 153let taken = occupy ( 0 ) 154let port = taken . 1 155let #( root , _ , home ) = fixture () 156let fixed = fake ( oauth . Callback ( "127.0.0.1" , port , "/cb" , True )) 157let #( _ , failed ) = start ( home , fixed ) 158assert field ( failed , "state" ) == "failed" 159assert string . contains ( field ( failed , "failure" ), "callback listener" ) 160let login = fake ( oauth . Callback ( "127.0.0.1" , port , "/cb" , False )) 161let #( id , flow ) = start ( home , login ) 162let assert Ok ( redirect ) = 163list . key_find ( query ( field ( flow , "url" )), "redirect_uri" ) 164assert ! string . contains ( redirect , ":" <> int_to_string ( port ) <> "/" ) 165let assert Ok ( cancelled ) = oauth . cancel_identified ( home , id , [ login ]) 166assert field ( cancelled , "state" ) == "cancelled" 167release ( taken ) 168cleanup ( root ) 169} 170 171@external ( erlang , "albedo_oauth_test_support" , "identity" ) 172fn identity () -> String 173 174@external ( erlang , "erlang" , "integer_to_binary" ) 175fn int_to_string ( value : Int ) -> String 176 177@external ( erlang , "albedo_oauth_test_support" , "occupy" ) 178fn occupy ( port : Int ) -> #( dynamic . Dynamic , Int ) 179 180@external ( erlang , "albedo_oauth_test_support" , "release" ) 181fn release ( taken : #( dynamic . Dynamic , Int )) -> Nil 182 183@external ( erlang , "albedo_skills_test_support" , "fixture" ) 184fn fixture () -> #( String , String , String ) 185 186@external ( erlang , "albedo_skills_test_support" , "cleanup" ) 187fn cleanup ( root : String ) -> Nil