okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
82b9457
main
1// Webhook form differential updates, ephemeral secret protection, session picker filtering, 2// the list filter and detail pane, and confirm key handling. 3// Differential step generation, modal reveal dismissal guards, and what the list shows 4// before a key is pressed operate inside unexported state; daemon E2E only sees submitted 5// mutations and cannot verify an unchanged form or an unsent question. 6package tui 7 8import ( 9"albedo/cli/internal/daemon" 10"errors" 11"net/http" 12"strings" 13"testing" 14 15 tea"charm.land/bubbletea/v2" 16"github.com/charmbracelet/x/ansi" 17) 18 19var webhookSessions = []daemon.Session { 20 {ID :"s" ,Title :"infra bot" ,Workspace :"/srv/infra" }, 21 {ID :"t" ,Title :"release notes" ,Workspace :"/srv/docs" }, 22} 23 24func loadedWebhooksPage (t * testing.T )WebhooksPageModel { 25t .Helper () 26m := NewWebhooksPageModel (nil ,"s" ) 27m .SetSize (100 ,30 ) 28m ,_ = m .Update (webhooksLoadedMsg {Gen :m .Generation ,Mounted :true ,Sessions :webhookSessions ,Hooks : []webhookEntry { 29 {ID :"wh1" ,Session :"s" ,Name :"deploy" ,Enabled :true ,URL :"/extensions/webhooks/hooks/wh1/deliveries" ,Header :"x-hub-signature-256" ,Prefix :"sha256=" ,Queued :2 ,Deferred :"session busy" }, 30 {ID :"wh2" ,Session :"t" ,Name :"grafana" ,URL :"/extensions/webhooks/hooks/wh2/deliveries" ,Header :"x-albedo-signature" ,Prefix :"sha256=" }, 31 }}) 32return m 33} 34 35func TestWebhookEditOnlySendsWhatChanged (t * testing.T ) { 36m := loadedWebhooksPage (t ) 37m ,_ = m .Update (tea.KeyPressMsg {Code :'e' ,Mod :tea .ModCtrl }) 38if m .Form == nil || m .Form .current ()!= hookFieldSecret || m .Form .Chosen != "s" { 39t .Fatal ("enter should edit the selected hook, starting at its secret" ) 40 } 41if steps ,_ ,_ := m .Form .steps ();len (steps )!= 0 { 42t .Fatalf ("unchanged form produced %v" ,steps ) 43 } 44m .Form .Inputs [hookFieldSecret ].SetValue ("a-new-sixteen-byte-secret" ) 45steps ,_ ,err := m .Form .steps () 46if err != nil || len (steps )!= 1 || steps [0 ]!= (daemon.WebhookRequest {Action :daemon .WebhookRotate ,HookID :"wh1" ,Secret :"a-new-sixteen-byte-secret" }) { 47t .Fatalf ("steps = %v, %v" ,steps ,err ) 48 } 49if strings .Contains (m .View (),"a-new-sixteen-byte-secret" ) { 50t .Fatal ("an entered secret was rendered" ) 51 } 52} 53 54func TestGeneratedSecretIsShownUntilDismissed (t * testing.T ) { 55m := loadedWebhooksPage (t ) 56m .Saving = true 57m ,_ = m .Update (webhooksSavedMsg {Gen :m .Generation ,Notice :"added ci" ,Reveal :& webhookSecret {Hook :"ci" ,Secret :"whsec_generated" }}) 58if ! strings .Contains (m .View (),"whsec_generated" ) { 59t .Fatal ("generated secret was not shown" ) 60 } 61m ,_ = m .Update (tea.KeyPressMsg {Code :'d' ,Mod :tea .ModCtrl }) 62if m .Reveal == nil || m .Confirming () { 63t .Fatal ("keys other than enter must not dismiss the secret" ) 64 } 65m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 66if m .Reveal != nil || strings .Contains (m .View (),"whsec_generated" ) { 67t .Fatal ("secret should be gone once dismissed" ) 68 } 69} 70 71func TestWebhookFormPicksTheSessionToWake (t * testing.T ) { 72m := loadedWebhooksPage (t ) 73m ,_ = m .Update (tea.KeyPressMsg {Code :'o' ,Mod :tea .ModCtrl }) 74if m .Form .Chosen != "s" || ! strings .Contains (ansi .Strip (m .View ()),"session this session · infra bot · /srv/infra" ) { 75t .Fatalf ("the form should default to the session it was opened from:\n%s" ,ansi .Strip (m .View ())) 76 } 77m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyTab ,Mod :tea .ModShift }) 78for _ ,r := range "release" { 79m ,_ = m .Update (tea.KeyPressMsg {Code :r ,Text :string (r )}) 80 } 81if m .Form .Chosen != "t" { 82t .Fatalf ("filtering should choose the matching session, got %q" ,m .Form .Chosen ) 83 } 84m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyBackspace }) 85m .Form .Inputs [hookFieldSession ].SetValue ("" ) 86m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyLeft }) 87if m .Form .Chosen != "s" { 88t .Fatalf ("← should step back to the first session, got %q" ,m .Form .Chosen ) 89 } 90m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyRight }) 91m .Form .Inputs [hookFieldName ].SetValue ("notes" ) 92steps ,notice ,err := m .Form .steps () 93if err != nil || steps [0 ]!= (daemon.WebhookRequest {Action :daemon .WebhookCreate ,SessionID :"t" ,Name :"notes" ,Header :"x-albedo-signature" ,Prefix :"sha256=" })|| ! strings .Contains (notice ,"release notes" ) { 94t .Fatalf ("steps = %v, %q, %v" ,steps ,notice ,err ) 95 } 96} 97 98// A failed extension read cannot establish that deliveries are mounted. 99func TestWebhookLoadSurfacesExtensionFailure (t * testing.T ) { 100requests := 0 101conn := commandTestConnection (t ,func (w http.ResponseWriter ,_ * http.Request ) { 102requests ++ 103w .WriteHeader (http .StatusForbidden ) 104_ ,_ = w .Write ([]byte (`{"detail":"extension load refused"}` )) 105 }) 106m := NewWebhooksPageModel (conn ,"session" ) 107msg := m .loadCmd (m .Generation )().(webhooksLoadedMsg ) 108if msg .Err == nil || msg .Mounted || requests != 1 { 109t .Fatalf ("extension failure treated as mounted: %+v requests=%d" ,msg ,requests ) 110 } 111m ,_ = m .Update (msg ) 112if m .Loaded || m .Error == "" { 113t .Fatal ("extension failure did not surface in screen" ) 114 } 115} 116 117// Creation is already committed when a following signature update fails. 118func TestWebhookSaveRetainsCreatedSecretAfterLaterFailure (t * testing.T ) { 119requests := 0 120conn := commandTestConnection (t ,func (w http.ResponseWriter ,_ * http.Request ) { 121requests ++ 122if requests == 1 { 123w .WriteHeader (201 ) 124_ ,_ = w .Write ([]byte (`{"resource":{"url":"/extensions/webhooks/hooks/created?view=configuration","etag":"\"created-a\"","value":{"id":"created","session_id":"session","name":"deploy","signature_header":"x-albedo-signature","signature_prefix":"sha256=","enabled":true,"revision":"a","created_at":"2026-10-03T00:00:00Z","updated_at":"2026-10-03T00:00:00Z"}},"secret":"whsec_created","notification":{"state":"not_requested","code":null,"detail":null}}` )) 125return 126 } 127w .WriteHeader (http .StatusBadRequest ) 128_ ,_ = w .Write ([]byte (`{"detail":"signature refused"}` )) 129 }) 130m := NewWebhooksPageModel (conn ,"session" ) 131msg := m .save (m .Generation ,"added" , daemon.WebhookRequest {Action :daemon .WebhookCreate ,SessionID :"session" ,Name :"deploy" }, daemon.WebhookRequest {Action :daemon .WebhookSignature ,Header :"x-custom" ,Prefix :"" })().(webhooksSavedMsg ) 132if msg .Err == nil || msg .Reveal == nil || msg .Reveal .Secret != "whsec_created" || requests != 2 { 133t .Fatalf ("lost committed secret: %+v requests=%d" ,msg ,requests ) 134 } 135if _ ,ok := errors.AsType [* daemon.APIError ](msg .Err );! ok { 136t .Fatalf ("lost failure: %v" ,msg .Err ) 137 } 138m ,_ = m .Update (msg ) 139if m .Reveal == nil || m .Error == "" { 140t .Fatal ("partial success did not retain reveal and error" ) 141 } 142} 143 144// The list filter is live and fuzzy, so a letter is a search term rather than 145// an action; the pane shows the highlighted hook without a key press. 146func TestWebhookFilterNarrowsAndDetailShowsWithoutEnter (t * testing.T ) { 147m := loadedWebhooksPage (t ) 148plain := ansi .Strip (m .View ()) 149if ! strings .Contains (plain ,"wakes" )|| ! strings .Contains (plain ,"x-hub-signature-256" ) { 150t .Fatalf ("detail pane did not show the highlighted hook:\n%s" ,plain ) 151 } 152for _ ,r := range "grafana" { 153m ,_ = m .Update (tea.KeyPressMsg {Code :r ,Text :string (r )}) 154 } 155if len (m .shown )!= 1 || m .rows [m .shown [0 ].row ].name != "grafana" { 156t .Fatalf ("filter shows %+v" ,m .shown ) 157 } 158if m .Confirming ()|| m .Form != nil { 159t .Fatal ("typing opened an action" ) 160 } 161} 162 163// enter toggles a hook at once and ctrl+t toggles agent access at once; neither asks. 164func TestWebhookToggleAppliesWithoutAsking (t * testing.T ) { 165m := loadedWebhooksPage (t ) 166m ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 167if cmd == nil || ! m .Saving || m .Confirming () { 168t .Fatal ("enter did not toggle the hook directly" ) 169 } 170m = loadedWebhooksPage (t ) 171m ,cmd = m .Update (tea.KeyPressMsg {Code :'t' ,Mod :tea .ModCtrl }) 172if cmd == nil || ! m .Saving || m .Confirming () { 173t .Fatal ("ctrl+t did not toggle agent access directly" ) 174 } 175} 176 177// A new secret asks first; esc cancels, a stray key does nothing, and enter runs it. 178func TestWebhookNewSecretConfirmTakesEnterAndEscOnly (t * testing.T ) { 179m := loadedWebhooksPage (t ) 180m ,_ = m .Update (tea.KeyPressMsg {Code :'g' ,Mod :tea .ModCtrl }) 181if ! m .Confirming ()|| ! strings .Contains (ansi .Strip (m .View ()),"Replace the secret for deploy?" ) { 182t .Fatal ("ctrl+g did not ask before replacing the secret" ) 183 } 184m ,_ = m .Update (tea.KeyPressMsg {Code :'x' ,Text :"x" }) 185if ! m .Confirming ()|| m .Saving { 186t .Fatal ("a stray key changed the secret or dismissed the question" ) 187 } 188m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEscape }) 189if m .Confirming ()|| m .Saving { 190t .Fatal ("esc did not cancel the question" ) 191 } 192m ,_ = m .Update (tea.KeyPressMsg {Code :'g' ,Mod :tea .ModCtrl }) 193m ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 194if cmd == nil || ! m .Saving || m .Confirming () { 195t .Fatal ("enter did not confirm the new secret" ) 196 } 197} 198 199// Delete asks first, and its consequence stays in the question. 200func TestWebhookDeleteAsksWithItsConsequence (t * testing.T ) { 201m := loadedWebhooksPage (t ) 202m ,_ = m .Update (tea.KeyPressMsg {Code :'d' ,Mod :tea .ModCtrl }) 203if ! m .Confirming ()|| ! strings .Contains (ansi .Strip (m .View ()),"accepted deliveries stay in the inbox" ) { 204t .Fatal ("ctrl+d did not ask before deleting" ) 205 } 206m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEscape }) 207if m .Confirming ()|| len (m .Hooks )!= 2 { 208t .Fatal ("esc did not cancel the deletion" ) 209 } 210}