1//go:build linux
2
3package app
4
5// Attach-missing create prompt (Plan 003 E4).
6//
7// resolveAttachCreationIntent restores the `vev attach <missing>` create
8// prompt main once ran through the direct-dial preflight, but through the
9// broker: it connects over the same connectProductionClientBroker seam the
10// ordinary terminal composition uses, asks the destination daemon for its
11// session list (daemonReportsSessionMissing, starting it if needed), and on a
12// definite answer hands its already-open connection back so the run still
13// opens exactly one broker connection.
14
15import (
16 "context"
17 "errors"
18 "io"
19 "os"
20 "strings"
21 "syscall"
22 "testing"
23 "time"
24
25 "github.com/stretchr/testify/mock"
26 "github.com/stretchr/testify/require"
27
28 "github.com/bnema/vev/internal/adapters/term"
29 "github.com/bnema/vev/internal/ports"
30 portsmocks "github.com/bnema/vev/internal/ports/mocks"
31 "github.com/bnema/vev/internal/protocol"
32 "github.com/bnema/vev/internal/usecase/client"
33 "github.com/bnema/vev/pkg/rawterm"
34)
35
36// interactiveProbe injects the console decision, standing in for the
37// production probe so the prompt matrix runs without owning a real TTY.
38func interactiveProbe(interactive bool) func(ports.Terminal) bool {
39 return func(ports.Terminal) bool { return interactive }
40}
41
42// withAttachInteractiveConsole installs a scripted console decision for the
43// duration of a test.
44func withAttachInteractiveConsole(t *testing.T, interactive bool) {
45 t.Helper()
46 previous := attachInteractiveConsole
47 attachInteractiveConsole = interactiveProbe(interactive)
48 t.Cleanup(func() { attachInteractiveConsole = previous })
49}
50
51// withPreflightBroker installs a scripted connectProductionClientBroker seam
52// for the duration of a test, exactly like the terminal composition tests in
53// broker_client_test.go, so the preflight peek never reaches a production
54// broker.
55func withPreflightBroker(t *testing.T, connect func(context.Context) (ports.BrokerService, error)) {
56 t.Helper()
57 previous := connectProductionClientBroker
58 connectProductionClientBroker = connect
59 t.Cleanup(func() { connectProductionClientBroker = previous })
60}
61
62// withDaemonSessions installs a scripted daemon listing: the destination named
63// by the route answers with sessions, or listErr when set. The broker
64// catalogue is deliberately not consulted, matching production.
65func withDaemonSessions(t *testing.T, sessions map[string][]string, listErr error) *[]client.BrokerOperationRoute {
66 t.Helper()
67 var routes []client.BrokerOperationRoute
68 previous := attachSessionLister
69 attachSessionLister = func(_ context.Context, _ ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) {
70 routes = append(routes, route)
71 if listErr != nil {
72 return nil, listErr
73 }
74 key := route.Destination.Registration.Endpoint
75 var out []protocol.SessionInfo
76 for _, name := range sessions[key] {
77 out = append(out, protocol.SessionInfo{Name: name})
78 }
79 return out, nil
80 }
81 t.Cleanup(func() { attachSessionLister = previous })
82 return &routes
83}
84
85func TestDaemonReportsSessionMissing(t *testing.T) {
86 remote := "user@example.test"
87 tests := []struct {
88 name string
89 snapshot ports.BrokerSnapshot
90 daemon map[string][]string // listing per endpoint; "" is local
91 listErr error
92 remote string
93 target string
94 wantMissing bool
95 wantListed bool
96 }{
97 {name: "local session present", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "work", wantListed: true},
98 {name: "local session absent", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "gone", wantMissing: true, wantListed: true},
99 {name: "catalogue empty but daemon restored it", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"vev"}}, target: "vev", wantListed: true},
100 {name: "daemon listing failed proves nothing", snapshot: terminalCompositionSnapshot(nil, nil), listErr: errors.New("unreachable"), target: "gone", wantListed: true},
101 {name: "remote session present", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "work", wantListed: true},
102 {name: "remote session absent", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "gone", wantMissing: true, wantListed: true},
103 {name: "remote host not configured", snapshot: terminalCompositionSnapshot(nil, nil), remote: "user@elsewhere.test", target: "work"},
104 }
105 for _, tc := range tests {
106 t.Run(tc.name, func(t *testing.T) {
107 routes := withDaemonSessions(t, tc.daemon, tc.listErr)
108 service := portsmocks.NewMockBrokerService(t)
109 service.EXPECT().Snapshot().Return(tc.snapshot).Once()
110
111 require.Equal(t, tc.wantMissing, daemonReportsSessionMissing(context.Background(), service, tc.remote, tc.target))
112 if !tc.wantListed {
113 require.Empty(t, *routes, "an unconfigured host is never listed")
114 return
115 }
116 require.Len(t, *routes, 1)
117 route := (*routes)[0]
118 require.Equal(t, tc.snapshot.Epoch, route.Epoch)
119 require.Equal(t, tc.remote == "", route.Destination.Local)
120 if tc.remote != "" {
121 observation, _ := tc.snapshot.Find(tc.remote)
122 require.True(t, route.Destination.Registration.Equal(observation.Registration))
123 }
124 })
125 }
126}
127
128func TestResolveAttachCreationIntentSkipsNonAttach(t *testing.T) {
129 for _, tc := range []struct {
130 name string
131 intent uint8
132 target string
133 }{
134 {name: "create", intent: protocol.IntentNew, target: "scratch"},
135 {name: "ephemeral", intent: protocol.IntentEphemeral, target: ""},
136 {name: "attach with no name", intent: protocol.IntentAttach, target: ""},
137 } {
138 t.Run(tc.name, func(t *testing.T) {
139 connected := false
140 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) {
141 connected = true
142 return nil, errors.New("must not connect")
143 })
144 intent, service, err := resolveAttachCreationIntent(context.Background(), tc.intent, tc.target, "", nil)
145 require.NoError(t, err)
146 require.Equal(t, tc.intent, intent)
147 require.Nil(t, service)
148 require.False(t, connected, "a non-attach or nameless intent never peeks the broker")
149 })
150 }
151}
152
153func TestResolveAttachCreationIntentBrokerUnavailableKeepsAttach(t *testing.T) {
154 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) {
155 return nil, errors.New("broker unreachable")
156 })
157 intent, service, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "scratch", "", nil)
158 require.NoError(t, err)
159 require.Equal(t, protocol.IntentAttach, intent)
160 require.Nil(t, service, "an undeterminable preflight must not hand back a connection")
161}
162
163func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) {
164 service := portsmocks.NewMockBrokerService(t)
165 service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once()
166 withDaemonSessions(t, map[string][]string{"": {"work"}}, nil)
167 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
168 withAttachInteractiveConsole(t, true) // must not even be consulted
169
170 intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "", nil)
171 require.NoError(t, err)
172 require.Equal(t, protocol.IntentAttach, intent)
173 require.Same(t, service, got, "the peeked connection is handed back for reuse")
174}
175
176func TestResolveAttachCreationIntentUnknownHostAttachesWithoutPrompt(t *testing.T) {
177 service := portsmocks.NewMockBrokerService(t)
178 service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()
179 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
180 withAttachInteractiveConsole(t, true)
181
182 intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "user@elsewhere.test", nil)
183 require.NoError(t, err)
184 require.Equal(t, protocol.IntentAttach, intent)
185 require.Same(t, service, got)
186}
187
188func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) {
189 tests := []struct {
190 name string
191 remote string
192 answer string
193 interactive bool
194 wantIntent uint8
195 wantPromptPart string
196 wantNoPrompt bool
197 }{
198 {name: "local confirm creates", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `vev: session "gone" doesn't exist, want to create and attach to it? [y/N]`},
199 {name: "local decline attaches", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "gone"},
200 {name: "local empty answer attaches", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach},
201 {name: "local unknown answer attaches", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "[y/N]"},
202 {name: "non-interactive console attaches without prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true},
203 {name: "remote confirm creates", remote: "user@example.test", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "gone" doesn't exist`},
204 }
205 for _, tc := range tests {
206 t.Run(tc.name, func(t *testing.T) {
207 var snapshot ports.BrokerSnapshot
208 if tc.remote == "" {
209 snapshot = terminalCompositionSnapshot([]string{"work"}, nil)
210 } else {
211 snapshot = terminalCompositionSnapshot(nil, map[string][]string{tc.remote: {"work"}})
212 }
213 service := portsmocks.NewMockBrokerService(t)
214 service.EXPECT().Snapshot().Return(snapshot).Once()
215 withDaemonSessions(t, map[string][]string{tc.remote: {"work"}}, nil)
216 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
217 withAttachInteractiveConsole(t, tc.interactive)
218
219 terminal := portsmocks.NewMockTerminal(t)
220 var promptOut strings.Builder
221 terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe()
222 terminal.EXPECT().Out().Return(&promptOut).Maybe()
223 terminal.EXPECT().Flush().Return(nil).Maybe()
224
225 intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "gone", tc.remote, terminal)
226 require.NoError(t, err)
227 require.Equal(t, tc.wantIntent, intent)
228 require.Same(t, service, got, "the peeked connection is always handed back on a definite answer")
229 if tc.wantPromptPart != "" {
230 require.Contains(t, promptOut.String(), tc.wantPromptPart)
231 }
232 if tc.wantNoPrompt {
233 require.Empty(t, promptOut.String())
234 }
235 })
236 }
237}
238
239func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) {
240 service := portsmocks.NewMockBrokerService(t)
241 service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once()
242 service.EXPECT().Close().Return(nil).Once()
243 withDaemonSessions(t, nil, nil)
244 withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
245 withAttachInteractiveConsole(t, true)
246
247 terminal := portsmocks.NewMockTerminal(t)
248 reader, writer := io.Pipe()
249 t.Cleanup(func() { _ = reader.Close(); _ = writer.Close() })
250 terminal.EXPECT().In().Return(reader).Maybe()
251 terminal.EXPECT().Out().Return(io.Discard).Maybe()
252 terminal.EXPECT().Flush().Return(nil).Maybe()
253
254 ctx, cancel := context.WithCancel(context.Background())
255 done := make(chan struct {
256 intent uint8
257 service ports.BrokerService
258 err error
259 }, 1)
260 go func() {
261 intent, got, err := resolveAttachCreationIntent(ctx, protocol.IntentAttach, "gone", "", terminal)
262 done <- struct {
263 intent uint8
264 service ports.BrokerService
265 err error
266 }{intent, got, err}
267 }()
268 cancel()
269 select {
270 case result := <-done:
271 require.ErrorIs(t, result.err, context.Canceled)
272 require.Nil(t, result.service, "a cancelled prompt closes and drops the peeked connection")
273 case <-time.After(5 * time.Second):
274 t.Fatal("resolveAttachCreationIntent did not release on cancellation")
275 }
276}
277
278func TestConfirmMissingSessionCreate(t *testing.T) {
279 tests := []struct {
280 name string
281 answer string
282 interactive bool
283 wantIntent uint8
284 wantPromptPart string
285 wantNoPrompt bool
286 }{
287 {name: "accepts yes", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "scratch" doesn't exist, want to create and attach to it? [y/N]`},
288 {name: "accepts yes word", answer: "yes\n", interactive: true, wantIntent: protocol.IntentNew},
289 {name: "declines no", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach},
290 {name: "defaults no on empty answer", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach},
291 {name: "defaults no on unknown answer", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach},
292 {name: "non-interactive console skips the prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true},
293 }
294 for _, tc := range tests {
295 t.Run(tc.name, func(t *testing.T) {
296 withAttachInteractiveConsole(t, tc.interactive)
297 terminal := portsmocks.NewMockTerminal(t)
298 var out strings.Builder
299 terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe()
300 terminal.EXPECT().Out().Return(&out).Maybe()
301 terminal.EXPECT().Flush().Return(nil).Maybe()
302
303 intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal)
304 require.NoError(t, err)
305 require.Equal(t, tc.wantIntent, intent)
306 if tc.wantPromptPart != "" {
307 require.Contains(t, out.String(), tc.wantPromptPart)
308 }
309 if tc.wantNoPrompt {
310 require.Empty(t, out.String())
311 }
312 })
313 }
314}
315
316// TestConfirmMissingSessionCreateFailureAttaches covers a console that cannot
317// show the question: the prompt must keep the plain attach path instead of
318// consuming an answer nobody was asked for. The input blocks forever, so a
319// prompt that reads it would only return on context expiry.
320func TestConfirmMissingSessionCreateFailureAttaches(t *testing.T) {
321 closedOut, err := os.CreateTemp(t.TempDir(), "vev-console")
322 require.NoError(t, err)
323 require.NoError(t, closedOut.Close())
324
325 for _, tc := range []struct {
326 name string
327 out io.Writer
328 flushErr error
329 }{
330 {name: "write fails", out: closedOut},
331 {name: "flush fails", out: io.Discard, flushErr: errors.New("console gone")},
332 } {
333 t.Run(tc.name, func(t *testing.T) {
334 withAttachInteractiveConsole(t, true)
335 consoleIn, consoleOut, err := os.Pipe()
336 require.NoError(t, err)
337 t.Cleanup(func() { _ = consoleIn.Close(); _ = consoleOut.Close() })
338
339 terminal := portsmocks.NewMockTerminal(t)
340 terminal.EXPECT().In().Return(consoleIn).Maybe()
341 terminal.EXPECT().Out().Return(tc.out).Maybe()
342 terminal.EXPECT().Flush().Return(tc.flushErr).Maybe()
343
344 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
345 defer cancel()
346 intent, err := confirmMissingSessionCreate(ctx, "scratch", terminal)
347 require.NoError(t, err, "an unusable console must not fail the attach")
348 require.Equal(t, protocol.IntentAttach, intent)
349 })
350 }
351}
352
353// TestConfirmMissingSessionCreateFlushesQuestion drives the real terminal
354// adapter, whose output is buffered: the prompt has to flush the question
355// before it blocks on the answer, or the user waits in front of a blank
356// console and the question only shows up after the client repaints.
357func TestConfirmMissingSessionCreateFlushesQuestion(t *testing.T) {
358 withAttachInteractiveConsole(t, true)
359 consoleIn, answers, err := os.Pipe()
360 require.NoError(t, err)
361 questions, consoleOut, err := os.Pipe()
362 require.NoError(t, err)
363 t.Cleanup(func() {
364 _ = consoleIn.Close()
365 _ = answers.Close()
366 _ = questions.Close()
367 _ = consoleOut.Close()
368 })
369
370 terminal := term.NewWithFiles(consoleIn, consoleOut)
371 done := make(chan struct {
372 intent uint8
373 err error
374 }, 1)
375 go func() {
376 intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal)
377 done <- struct {
378 intent uint8
379 err error
380 }{intent, err}
381 }()
382
383 question := readAttachPrompt(t, questions)
384 require.Contains(t, question, "want to create and attach to it? [y/N] ", "the question must reach the console before the prompt reads an answer")
385
386 _, err = answers.WriteString("y\n")
387 require.NoError(t, err)
388 select {
389 case result := <-done:
390 require.NoError(t, result.err)
391 require.Equal(t, protocol.IntentNew, result.intent)
392 case <-time.After(5 * time.Second):
393 t.Fatal("the prompt did not release after the answer")
394 }
395}
396
397// readAttachPrompt returns the console output that ends the create question,
398// failing the test instead of hanging when nothing arrives.
399func readAttachPrompt(t *testing.T, console *os.File) string {
400 t.Helper()
401 require.NoError(t, console.SetReadDeadline(time.Now().Add(5*time.Second)))
402 var output []byte
403 frame := make([]byte, 64)
404 for {
405 read, err := console.Read(frame)
406 output = append(output, frame[:read]...)
407 if strings.Contains(string(output), "[y/N] ") {
408 return string(output)
409 }
410 if err != nil {
411 t.Fatalf("prompt never reached the console (read %q: %v)", output, err)
412 }
413 }
414}
415
416// TestTerminalIsInteractive covers the production probe on both sides of the
417// console check: a real terminal file and the streams that are not consoles.
418func TestTerminalIsInteractive(t *testing.T) {
419 require.False(t, terminalIsInteractive(nil), "a missing terminal cannot prompt")
420 pty := openAttachPtySlave(t)
421 require.True(t, terminalIsInteractive(term.NewWithFiles(pty, pty)), "a real terminal file is a console")
422
423 console, writer, err := os.Pipe()
424 require.NoError(t, err)
425 t.Cleanup(func() { _ = console.Close(); _ = writer.Close() })
426
427 for _, tc := range []struct {
428 name string
429 in io.Reader
430 }{
431 {name: "detached reader", in: strings.NewReader("y\n")},
432 {name: "non-terminal file", in: console},
433 } {
434 t.Run(tc.name, func(t *testing.T) {
435 terminal := portsmocks.NewMockTerminal(t)
436 terminal.EXPECT().In().Return(tc.in).Maybe()
437 require.False(t, terminalIsInteractive(terminal))
438 })
439 }
440}
441
442// openAttachPtySlave returns the slave side of a real PTY pair: the only way
443// to hand the probe an *os.File the kernel reports as a terminal.
444func openAttachPtySlave(t *testing.T) *os.File {
445 t.Helper()
446 master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
447 if err != nil {
448 t.Skipf("open /dev/ptmx: %v", err)
449 }
450 t.Cleanup(func() { _ = master.Close() })
451 slave, err := rawterm.PreparePty(int(master.Fd()))
452 require.NoError(t, err, "prepare pty")
453 t.Cleanup(func() { _ = slave.Close() })
454 return slave
455}
456
457func TestPreconnectedBrokerConnectorReusesThenFallsBack(t *testing.T) {
458 first := portsmocks.NewMockBrokerService(t)
459 second := portsmocks.NewMockBrokerService(t)
460 fallback := portsmocks.NewMockBrokerConnector(t)
461 fallback.EXPECT().Connect(mock.Anything).Return(second, nil).Once()
462 connector := withPreconnected(fallback, first)
463
464 got, err := connector.Connect(context.Background())
465 require.NoError(t, err)
466 require.Same(t, first, got, "the first Connect reuses the preflight's own connection")
467
468 got, err = connector.Connect(context.Background())
469 require.NoError(t, err)
470 require.Same(t, second, got, "a later Connect reconnects through the fallback")
471}
472
473func TestWithPreconnectedNilPassesThroughUnchanged(t *testing.T) {
474 fallback := portsmocks.NewMockBrokerConnector(t)
475 fallback.EXPECT().Connect(mock.Anything).Return(nil, nil).Once()
476 got := withPreconnected(fallback, nil)
477 service, err := got.Connect(context.Background())
478 require.NoError(t, err)
479 require.Nil(t, service)
480}