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}