1//go:build linux
2
3package app
4
5import (
6 "bytes"
7 "context"
8 "io"
9 "log/slog"
10 "net"
11 "sync"
12 "testing"
13
14 "github.com/bnema/vev/internal/adapters/sshstdio"
15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 "github.com/bnema/vev/internal/usecase/client"
19 "github.com/stretchr/testify/require"
20)
21
22type acceptanceRemoteFactory struct {
23 mu sync.Mutex
24 calls []string
25 handoff map[string]ports.AttachTarget
26 outputs chan string
27}
28
29func (f *acceptanceRemoteFactory) DialerForRemote(target string, _ string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
30 f.mu.Lock()
31 f.calls = append(f.calls, target)
32 targetHandoff := f.handoff[target]
33 f.mu.Unlock()
34 return acceptanceRemoteDialer{target: target, handoff: targetHandoff, outputs: f.outputs}, nil
35}
36
37type acceptanceRemoteDialer struct {
38 target string
39 handoff ports.AttachTarget
40 outputs chan<- string
41}
42
43func (d acceptanceRemoteDialer) Dial(context.Context) (ports.Transport, error) {
44 clientConn, serverConn := net.Pipe()
45 clientTransport := sshstdio.NewTransport(clientConn, clientConn, clientConn.Close)
46 serverTransport := sshstdio.NewTransport(serverConn, serverConn, serverConn.Close)
47 go serveAcceptanceRemote(serverTransport, d.target, d.handoff, d.outputs)
48 return clientTransport, nil
49}
50
51func serveAcceptanceRemote(tr ports.Transport, target string, handoff ports.AttachTarget, outputs chan<- string) {
52 defer func() { _ = tr.Close() }()
53 welcomed := false
54 for {
55 frame, err := tr.Recv()
56 if err != nil {
57 return
58 }
59 if !welcomed {
60 if frame.Type != ports.MsgHello {
61 continue
62 }
63 if err := tr.Send(ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: target})}); err != nil {
64 return
65 }
66 welcomed = true
67 continue
68 }
69 if handoff.Endpoint != "" {
70 _ = tr.Send(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(handoff)})
71 for {
72 if _, err := tr.Recv(); err != nil {
73 return
74 }
75 }
76 }
77 data := []byte("remote:" + target)
78 if outputs != nil {
79 outputs <- string(data)
80 }
81 payload, err := ports.MarshalOutput(ports.Output{
82 Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data,
83 })
84 if err != nil {
85 return
86 }
87 if err := tr.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
88 return
89 }
90 _ = tr.Send(ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})})
91 for {
92 if _, err := tr.Recv(); err != nil {
93 return
94 }
95 }
96 }
97}
98
99type acceptanceRemoteTerminal struct {
100 in io.Reader
101 out bytes.Buffer
102}
103
104func (t *acceptanceRemoteTerminal) EnterRaw() (func() error, error) {
105 return func() error { return nil }, nil
106}
107func (*acceptanceRemoteTerminal) Size() (domain.Size, error) {
108 return domain.Size{Cols: 80, Rows: 24}, nil
109}
110func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Size { return nil }
111func (t *acceptanceRemoteTerminal) In() io.Reader { return t.in }
112func (t *acceptanceRemoteTerminal) Out() io.Writer { return &t.out }
113func (*acceptanceRemoteTerminal) Flush() error { return nil }
114
115func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
116 outputs := make(chan string, 4)
117 factory := &acceptanceRemoteFactory{outputs: outputs}
118 hostStore := portsmocks.NewMockRemoteHostStore(t)
119 for _, host := range []string{"direct.example", "picker.example", "picked.example"} {
120 hostStore.EXPECT().Remember(host).Return(nil).Once()
121 }
122 localCalls := 0
123 terminals := make([]*acceptanceRemoteTerminal, 0, 3)
124 deps := runAttachDeps{
125 hostStore: hostStore,
126 localDialer: func() ports.Dialer {
127 localCalls++
128 return acceptanceRemoteDialer{}
129 },
130 remoteDialerFactory: factory,
131 selectedRemoteTransport: string(ports.RemoteTransportStdio),
132 runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
133 term := &acceptanceRemoteTerminal{in: bytes.NewReader(nil)}
134 terminals = append(terminals, term)
135 deps.Terminal = term
136 deps.Logger = slog.New(slog.DiscardHandler)
137 deps.Remote = true
138 result := client.NewRunner(deps).Run(ctx, request)
139 if request.SessionName == "work" {
140 require.NoError(t, result, "the client handoff completes before the test injects the composition-root handoff")
141 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach}}
142 }
143 return result
144 },
145 }
146
147 wireTransport, err := (acceptanceRemoteDialer{
148 target: "picker-wire.example",
149 handoff: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach},
150 }).Dial(context.Background())
151 require.NoError(t, err)
152 require.NoError(t, wireTransport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
153 Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24},
154 })}))
155 welcome, err := wireTransport.Recv()
156 require.NoError(t, err)
157 require.Equal(t, ports.MsgWelcome, welcome.Type)
158 require.NoError(t, wireTransport.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{})}))
159 attachTarget, err := wireTransport.Recv()
160 require.NoError(t, err)
161 target, err := ports.UnmarshalAttachTarget(attachTarget.Payload)
162 require.NoError(t, err)
163 require.Equal(t, ports.MsgAttachTarget, attachTarget.Type)
164 require.Equal(t, "picked.example", target.Endpoint)
165 require.NoError(t, wireTransport.Close())
166
167 require.NoError(t, runAttachWithDeps(context.Background(), ports.IntentAttach, "direct", "direct.example", "", nil, deps))
168 require.NoError(t, runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "picker.example", "", nil, deps))
169 factory.mu.Lock()
170 calls := append([]string(nil), factory.calls...)
171 factory.mu.Unlock()
172 require.Equal(t, []string{"direct.example", "picker.example", "picked.example"}, calls)
173 require.Zero(t, localCalls, "remote direct and picker connections must not create a local shadow")
174 require.Len(t, terminals, 3)
175 require.Equal(t, []string{"remote:direct.example", "remote:picker.example", "remote:picked.example"}, []string{<-outputs, <-outputs, <-outputs})
176}