okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: webhooks on listView: filter, pane, confirm only for destructive actions, add on ctrl+o82b9457

main
9.0 KiB210 linesraw
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 {
25	t.Helper()
26	m := NewWebhooksPageModel(nil, "s")
27	m.SetSize(100, 30)
28	m, _ = 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	}})
32	return m
33}
34
35func TestWebhookEditOnlySendsWhatChanged(t *testing.T) {
36	m := loadedWebhooksPage(t)
37	m, _ = m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})
38	if m.Form == nil || m.Form.current() != hookFieldSecret || m.Form.Chosen != "s" {
39		t.Fatal("enter should edit the selected hook, starting at its secret")
40	}
41	if steps, _, _ := m.Form.steps(); len(steps) != 0 {
42		t.Fatalf("unchanged form produced %v", steps)
43	}
44	m.Form.Inputs[hookFieldSecret].SetValue("a-new-sixteen-byte-secret")
45	steps, _, err := m.Form.steps()
46	if err != nil || len(steps) != 1 || steps[0] != (daemon.WebhookRequest{Action: daemon.WebhookRotate, HookID: "wh1", Secret: "a-new-sixteen-byte-secret"}) {
47		t.Fatalf("steps = %v, %v", steps, err)
48	}
49	if strings.Contains(m.View(), "a-new-sixteen-byte-secret") {
50		t.Fatal("an entered secret was rendered")
51	}
52}
53
54func TestGeneratedSecretIsShownUntilDismissed(t *testing.T) {
55	m := loadedWebhooksPage(t)
56	m.Saving = true
57	m, _ = m.Update(webhooksSavedMsg{Gen: m.Generation, Notice: "added ci", Reveal: &webhookSecret{Hook: "ci", Secret: "whsec_generated"}})
58	if !strings.Contains(m.View(), "whsec_generated") {
59		t.Fatal("generated secret was not shown")
60	}
61	m, _ = m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})
62	if m.Reveal == nil || m.Confirming() {
63		t.Fatal("keys other than enter must not dismiss the secret")
64	}
65	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
66	if m.Reveal != nil || strings.Contains(m.View(), "whsec_generated") {
67		t.Fatal("secret should be gone once dismissed")
68	}
69}
70
71func TestWebhookFormPicksTheSessionToWake(t *testing.T) {
72	m := loadedWebhooksPage(t)
73	m, _ = m.Update(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
74	if m.Form.Chosen != "s" || !strings.Contains(ansi.Strip(m.View()), "session    this session · infra bot · /srv/infra") {
75		t.Fatalf("the form should default to the session it was opened from:\n%s", ansi.Strip(m.View()))
76	}
77	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
78	for _, r := range "release" {
79		m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
80	}
81	if m.Form.Chosen != "t" {
82		t.Fatalf("filtering should choose the matching session, got %q", m.Form.Chosen)
83	}
84	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
85	m.Form.Inputs[hookFieldSession].SetValue("")
86	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
87	if m.Form.Chosen != "s" {
88		t.Fatalf("← should step back to the first session, got %q", m.Form.Chosen)
89	}
90	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
91	m.Form.Inputs[hookFieldName].SetValue("notes")
92	steps, notice, err := m.Form.steps()
93	if err != nil || steps[0] != (daemon.WebhookRequest{Action: daemon.WebhookCreate, SessionID: "t", Name: "notes", Header: "x-albedo-signature", Prefix: "sha256="}) || !strings.Contains(notice, "release notes") {
94		t.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) {
100	requests := 0
101	conn := commandTestConnection(t, func(w http.ResponseWriter, _ *http.Request) {
102		requests++
103		w.WriteHeader(http.StatusForbidden)
104		_, _ = w.Write([]byte(`{"detail":"extension load refused"}`))
105	})
106	m := NewWebhooksPageModel(conn, "session")
107	msg := m.loadCmd(m.Generation)().(webhooksLoadedMsg)
108	if msg.Err == nil || msg.Mounted || requests != 1 {
109		t.Fatalf("extension failure treated as mounted: %+v requests=%d", msg, requests)
110	}
111	m, _ = m.Update(msg)
112	if m.Loaded || m.Error == "" {
113		t.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) {
119	requests := 0
120	conn := commandTestConnection(t, func(w http.ResponseWriter, _ *http.Request) {
121		requests++
122		if requests == 1 {
123			w.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}}`))
125			return
126		}
127		w.WriteHeader(http.StatusBadRequest)
128		_, _ = w.Write([]byte(`{"detail":"signature refused"}`))
129	})
130	m := NewWebhooksPageModel(conn, "session")
131	msg := m.save(m.Generation, "added", daemon.WebhookRequest{Action: daemon.WebhookCreate, SessionID: "session", Name: "deploy"}, daemon.WebhookRequest{Action: daemon.WebhookSignature, Header: "x-custom", Prefix: ""})().(webhooksSavedMsg)
132	if msg.Err == nil || msg.Reveal == nil || msg.Reveal.Secret != "whsec_created" || requests != 2 {
133		t.Fatalf("lost committed secret: %+v requests=%d", msg, requests)
134	}
135	if _, ok := errors.AsType[*daemon.APIError](msg.Err); !ok {
136		t.Fatalf("lost failure: %v", msg.Err)
137	}
138	m, _ = m.Update(msg)
139	if m.Reveal == nil || m.Error == "" {
140		t.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) {
147	m := loadedWebhooksPage(t)
148	plain := ansi.Strip(m.View())
149	if !strings.Contains(plain, "wakes") || !strings.Contains(plain, "x-hub-signature-256") {
150		t.Fatalf("detail pane did not show the highlighted hook:\n%s", plain)
151	}
152	for _, r := range "grafana" {
153		m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
154	}
155	if len(m.shown) != 1 || m.rows[m.shown[0].row].name != "grafana" {
156		t.Fatalf("filter shows %+v", m.shown)
157	}
158	if m.Confirming() || m.Form != nil {
159		t.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) {
165	m := loadedWebhooksPage(t)
166	m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
167	if cmd == nil || !m.Saving || m.Confirming() {
168		t.Fatal("enter did not toggle the hook directly")
169	}
170	m = loadedWebhooksPage(t)
171	m, cmd = m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
172	if cmd == nil || !m.Saving || m.Confirming() {
173		t.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) {
179	m := loadedWebhooksPage(t)
180	m, _ = m.Update(tea.KeyPressMsg{Code: 'g', Mod: tea.ModCtrl})
181	if !m.Confirming() || !strings.Contains(ansi.Strip(m.View()), "Replace the secret for deploy?") {
182		t.Fatal("ctrl+g did not ask before replacing the secret")
183	}
184	m, _ = m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})
185	if !m.Confirming() || m.Saving {
186		t.Fatal("a stray key changed the secret or dismissed the question")
187	}
188	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
189	if m.Confirming() || m.Saving {
190		t.Fatal("esc did not cancel the question")
191	}
192	m, _ = m.Update(tea.KeyPressMsg{Code: 'g', Mod: tea.ModCtrl})
193	m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
194	if cmd == nil || !m.Saving || m.Confirming() {
195		t.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) {
201	m := loadedWebhooksPage(t)
202	m, _ = m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})
203	if !m.Confirming() || !strings.Contains(ansi.Strip(m.View()), "accepted deliveries stay in the inbox") {
204		t.Fatal("ctrl+d did not ask before deleting")
205	}
206	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
207	if m.Confirming() || len(m.Hooks) != 2 {
208		t.Fatal("esc did not cancel the deletion")
209	}
210}