v0.2.1 c196c81

auth_test.go RawHistoryFolder

1package visual
2
3import (
4	"context"
5	"io"
6	"sync"
7	"testing"
8	"time"
9
10	"github.com/bnema/neferafk/internal/adapters/auth"
11	"github.com/bnema/neferafk/internal/ports"
12	"github.com/bnema/nefergui"
13	"github.com/bnema/zerowrap"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16)
17
18const gen = 7
19
20func testLog() zerowrap.Logger { return zerowrap.New(zerowrap.Config{Output: io.Discard}) }
21
22// testWorker is the test's end of one worker's private pipes.
23type testWorker struct {
24	fromVisual *io.PipeReader // bootstrap and frames the visual wrote
25	toVisual   *io.PipeWriter // frames the worker answers with
26	killed     chan struct{}
27}
28
29// expectWorker queues one launch on l, backed by real in-memory pipes.
30func expectWorker(t *testing.T, l *mockworkerLauncher) *testWorker {
31	t.Helper()
32	inR, inW := io.Pipe()
33	outR, outW := io.Pipe()
34	w := &testWorker{fromVisual: inR, toVisual: outW, killed: make(chan struct{})}
35	proc := newMockworkerProcess(t)
36	proc.EXPECT().pipes().Return(inW, outR).Maybe()
37	proc.EXPECT().kill().Run(func() { close(w.killed) }).Return(nil).Maybe()
38	proc.EXPECT().wait().Return(nil).Maybe()
39	l.EXPECT().launch(mock.Anything).Return(proc, nil).Once()
40	t.Cleanup(func() { inR.Close(); outW.Close() })
41	return w
42}
43
44func (w *testWorker) bootstrap(t *testing.T) ports.AuthBootstrap {
45	t.Helper()
46	b, err := auth.ReadBootstrap(w.fromVisual)
47	require.NoError(t, err)
48	return b
49}
50
51func (w *testWorker) send(t *testing.T, f ports.AuthFrame) {
52	t.Helper()
53	f.Generation = gen
54	require.NoError(t, auth.WriteFrame(w.toVisual, f))
55}
56
57func (w *testWorker) ready(t *testing.T) {
58	w.send(t, ports.AuthFrame{Kind: ports.AuthReady, Code: ports.AuthPassword})
59}
60
61func (w *testWorker) result(t *testing.T, attempt uint64, c ports.AuthCode) {
62	w.send(t, ports.AuthFrame{Kind: ports.AuthResult, Code: c, Attempt: attempt})
63}
64
65func (w *testWorker) attempt(t *testing.T) ports.AuthFrame {
66	t.Helper()
67	f, err := auth.ReadFrame(w.fromVisual)
68	require.NoError(t, err)
69	return f
70}
71
72func recv[T any](t *testing.T, c <-chan T) T {
73	t.Helper()
74	select {
75	case v := <-c:
76		return v
77	case <-time.After(5 * time.Second):
78		t.Fatal("timed out")
79	}
80	panic("unreachable")
81}
82
83func boot() ports.AuthBootstrap {
84	return ports.AuthBootstrap{Generation: gen, PINSource: "env", EnvPIN: []byte("pin")}
85}
86
87func startCtl(t *testing.T, l workerLauncher, timeout time.Duration) *authController {
88	c := newAuthController(testLog(), l, boot(), timeout)
89	ctx, cancel := context.WithCancel(context.Background())
90	var wg sync.WaitGroup
91	wg.Add(1)
92	go func() { defer wg.Done(); c.run(ctx) }()
93	t.Cleanup(func() { cancel(); wg.Wait() })
94	return c
95}
96
97func TestSuccessRequestsUnlockOnlyAfterAuthSuccess(t *testing.T) {
98	l := newMockworkerLauncher(t)
99	w := expectWorker(t, l)
100	c := startCtl(t, l, time.Minute)
101	b := w.bootstrap(t)
102	require.Equal(t, uint64(gen), b.Generation)
103	w.ready(t)
104	c.submit([]byte("hunter2"))
105	f := w.attempt(t)
106	require.Equal(t, ports.AuthAttempt, f.Kind)
107	require.Equal(t, []byte("hunter2"), f.Payload)
108	select {
109	case <-c.unlock:
110		t.Fatal("unlock requested before the result")
111	case <-time.After(50 * time.Millisecond):
112	}
113	w.result(t, f.Attempt, ports.AuthSuccess)
114	recv(t, c.unlock)
115	require.True(t, c.succeeded.Load())
116}
117
118func TestDeniedStaysLockedAndAllowsRetry(t *testing.T) {
119	l := newMockworkerLauncher(t)
120	w := expectWorker(t, l)
121	c := startCtl(t, l, time.Minute)
122	w.bootstrap(t)
123	w.ready(t)
124	c.submit([]byte("bad"))
125	f := w.attempt(t)
126	w.result(t, f.Attempt, ports.AuthDenied)
127	for recv(t, c.status) != nefergui.LockFailed {
128	}
129	select {
130	case <-c.unlock:
131		t.Fatal("denied must not unlock")
132	case <-time.After(50 * time.Millisecond):
133	}
134	c.submit([]byte("good"))
135	f = w.attempt(t)
136	require.Equal(t, []byte("good"), f.Payload)
137	w.result(t, f.Attempt, ports.AuthSuccess)
138	recv(t, c.unlock)
139}
140
141func TestWorkerCrashStaysLockedAndRespawnsOnNextAttempt(t *testing.T) {
142	l := newMockworkerLauncher(t)
143	w1 := expectWorker(t, l)
144	c := startCtl(t, l, time.Minute)
145	w1.bootstrap(t)
146	w1.ready(t)
147	c.submit([]byte("x"))
148	w1.attempt(t)
149	w1.toVisual.Close() // worker died mid-attempt
150	recv(t, w1.killed)
151	for recv(t, c.status) != nefergui.LockFailed {
152	}
153	require.False(t, c.succeeded.Load())
154	w2 := expectWorker(t, l)
155	c.submit([]byte("again"))
156	w2.bootstrap(t)
157	w2.ready(t)
158	f := w2.attempt(t)
159	require.Equal(t, []byte("again"), f.Payload)
160	w2.result(t, f.Attempt, ports.AuthSuccess)
161	recv(t, c.unlock)
162}
163
164func TestWatchdogKillsHungWorkerAndStaysLocked(t *testing.T) {
165	l := newMockworkerLauncher(t)
166	w := expectWorker(t, l)
167	c := startCtl(t, l, 100*time.Millisecond)
168	w.bootstrap(t)
169	w.ready(t)
170	c.submit([]byte("x"))
171	w.attempt(t) // no result ever comes
172	recv(t, w.killed)
173	for recv(t, c.status) != nefergui.LockFailed {
174	}
175	select {
176	case <-c.unlock:
177		t.Fatal("timeout must not unlock")
178	default:
179	}
180}
181
182func TestSubmittedCopiesAreWiped(t *testing.T) {
183	l := newMockworkerLauncher(t)
184	w := expectWorker(t, l)
185	c := newAuthController(testLog(), l, boot(), time.Minute)
186	ctx := context.Background()
187	spawned := make(chan struct{})
188	go func() { c.spawn(ctx); close(spawned) }() // owner role: nothing else runs
189	w.bootstrap(t)
190	recv(t, spawned)
191	t.Cleanup(c.drop)
192	c.onFrame(frameMsg{f: ports.AuthFrame{Kind: ports.AuthReady, Code: ports.AuthPassword, Generation: gen}})
193
194	orig := []byte("hunter2")
195	c.submit(orig)
196	cp := <-c.submits
197	require.NotSame(t, &orig[0], &cp[0], "submit hands the controller a private copy")
198	got := make(chan []byte, 1)
199	go func() { got <- w.attempt(t).Payload }()
200	c.onSubmit(ctx, cp)
201	require.Equal(t, []byte("hunter2"), recv(t, got))
202	require.Equal(t, make([]byte, len("hunter2")), cp, "copy wiped once written to the worker pipe")
203}
204
205func TestPromptAndFallbackAreReflected(t *testing.T) {
206	l := newMockworkerLauncher(t)
207	w := expectWorker(t, l)
208	c := startCtl(t, l, time.Minute)
209	w.bootstrap(t)
210	w.send(t, ports.AuthFrame{Kind: ports.AuthReady, Code: ports.AuthFallbackPassword})
211	recv(t, c.wake)
212	require.Equal(t, promptFallback, promptKind(c.prompt.Load()))
213	c.submit([]byte("x"))
214	f := w.attempt(t)
215	w.send(t, ports.AuthFrame{Kind: ports.AuthPrompt, Code: ports.AuthPassword, Attempt: f.Attempt})
216	recv(t, c.wake)
217	require.Equal(t, promptMore, promptKind(c.prompt.Load()))
218}
219
220func TestDropDrainsAndWipesQueuedSubmissions(t *testing.T) {
221	l := newMockworkerLauncher(t)
222	c := newAuthController(testLog(), l, boot(), time.Minute)
223	queued := []byte("hunter2")
224	c.submits <- queued
225	c.drop()
226	require.Equal(t, make([]byte, len("hunter2")), queued, "queued secret wiped")
227	require.Empty(t, c.submits)
228
229	// A submission racing the controller's exit is wiped by a later drain.
230	late := []byte("late")
231	c.submits <- late
232	c.drainSubmits()
233	require.Equal(t, make([]byte, len("late")), late)
234}
235
236func TestRunExitDrainsQueuedSubmission(t *testing.T) {
237	l := newMockworkerLauncher(t)
238	w := expectWorker(t, l)
239	c := newAuthController(testLog(), l, boot(), time.Minute)
240	ctx, cancel := context.WithCancel(context.Background())
241	done := make(chan struct{})
242	go func() { c.run(ctx); close(done) }()
243	w.bootstrap(t)
244	cancel()
245	recv(t, done)
246	late := []byte("late-secret")
247	c.submits <- late // UI handed a secret over after the controller left
248	c.drainSubmits()
249	require.Equal(t, make([]byte, len("late-secret")), late)
250}
251
252// A frame for another generation or attempt must never unlock.
253func TestControllerKillsWorkerOnStaleGenerationOrAttempt(t *testing.T) {
254	cases := map[string]func(attempt uint64) ports.AuthFrame{
255		"wrong generation": func(a uint64) ports.AuthFrame {
256			return ports.AuthFrame{Kind: ports.AuthResult, Code: ports.AuthSuccess, Generation: gen + 1, Attempt: a}
257		},
258		"old attempt": func(a uint64) ports.AuthFrame {
259			return ports.AuthFrame{Kind: ports.AuthResult, Code: ports.AuthSuccess, Generation: gen, Attempt: a - 1}
260		},
261		"future attempt": func(a uint64) ports.AuthFrame {
262			return ports.AuthFrame{Kind: ports.AuthResult, Code: ports.AuthSuccess, Generation: gen, Attempt: a + 1}
263		},
264	}
265	for name, mk := range cases {
266		t.Run(name, func(t *testing.T) {
267			l := newMockworkerLauncher(t)
268			w := expectWorker(t, l)
269			c := startCtl(t, l, time.Minute)
270			w.bootstrap(t)
271			w.ready(t)
272			c.submit([]byte("first"))
273			w.result(t, w.attempt(t).Attempt, ports.AuthDenied)
274			for recv(t, c.status) != nefergui.LockFailed {
275			}
276			c.submit([]byte("second")) // attempt 2: attempt 1 is now stale
277			f := w.attempt(t)
278			require.EqualValues(t, 2, f.Attempt)
279			require.NoError(t, auth.WriteFrame(w.toVisual, mk(f.Attempt)))
280			recv(t, w.killed)
281			for recv(t, c.status) != nefergui.LockFailed {
282			}
283			select {
284			case <-c.unlock:
285				t.Fatal("stale frame unlocked")
286			case <-time.After(50 * time.Millisecond):
287			}
288			require.False(t, c.succeeded.Load())
289		})
290	}
291}
292
293// The watchdog must outlast the longest wrong-PIN delay, or killing the worker
294// mid-delay would restart its schedule and defeat the rate limit.
295func TestWatchdogOutlastsDenyDelay(t *testing.T) {
296	require.GreaterOrEqual(t, workerTimeout, 30*time.Second+auth.MaxDenyDelay)
297}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close