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}