v0.5.0 131f96f

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

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