1package app
2
3// CommandResult caller contract (explicit command-result slice). runCmd sends
4// exactly one CommandRequest with a unique nonzero RequestID and consumes
5// exactly one correlated CommandResult: a lost, wrong-typed, uncorrelated, or
6// malformed reply, or a deadline/cancellation after the send, is a typed
7// outcome-unknown that is never replayed and whose send count stays one. A
8// caller canceled before the send is a definite not-sent outcome, and a
9// definite daemon failure keeps its bounded error and exit code. Every case is
10// bounded and uses fake clocks or gates, never sleeps.
11
12import (
13 "context"
14 "io"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/stretchr/testify/mock"
20 "github.com/stretchr/testify/require"
21 "google.golang.org/protobuf/proto"
22
23 "github.com/bnema/vev/internal/adapters/sessionwire"
24 "github.com/bnema/vev/internal/ports"
25 portsmocks "github.com/bnema/vev/internal/ports/mocks"
26 "github.com/bnema/vev/internal/protocol"
27 "github.com/bnema/vev/internal/protocol/wire"
28 "github.com/bnema/vev/internal/usecase/daemon"
29)
30
31// countCommandRequests reports how many CommandRequests the transport placed on
32// the wire: exactly one is the no-replay invariant.
33func countCommandRequests(t *testing.T, transport *cmdTestTransport) int {
34 t.Helper()
35 count := 0
36 for _, frame := range transport.sent {
37 message, err := sessionwire.DecodeClientEnvelope(frame.Payload)
38 if err != nil {
39 continue
40 }
41 if _, ok := message.(protocol.CommandRequest); ok {
42 count++
43 }
44 }
45 return count
46}
47
48// runCmdReply drives one runCmdWithDeps invocation against a scripted transport.
49func runCmdReply(ctx context.Context, transport *cmdTestTransport, clock ports.Clock) error {
50 return runCmdWithDeps(ctx, cmdInvocation{slug: "split-right"}, cmdDeps{
51 stdout: io.Discard,
52 getenv: func(string) string { return "" },
53 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
54 clock: clock,
55 })
56}
57
58// rawServerEnvelope wraps already-serialized server envelope bytes without
59// re-encoding them. Fixtures that model hostile or corrupt frames use it so the
60// now-correct strict semantic encoder can never reject the fixture first.
61func rawServerEnvelope(raw []byte) wire.Envelope { return wire.Envelope{Payload: raw} }
62
63// malformedCommandOutcomeEnvelope marshals a server CommandResult whose
64// outcome/code combination the semantic contract forbids (a succeeded outcome
65// with a nonzero code). proto.Marshal deliberately bypasses
66// sessionwire.EncodeServerMessage, which refuses the semantic value outright.
67func malformedCommandOutcomeEnvelope(t *testing.T) wire.Envelope {
68 t.Helper()
69 raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: &wire.CommandResult{
70 RequestId: 1,
71 Outcome: wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED,
72 Code: uint32(protocol.ErrInternal),
73 }}})
74 require.NoError(t, err)
75 // The semantic encoder must refuse the same value, which proves this fixture
76 // really is a hand-built frame rather than a re-encoded semantic message.
77 _, encodeErr := sessionwire.EncodeServerMessage(protocol.CommandResult{
78 RequestID: 1, Outcome: protocol.CommandSucceeded, Code: protocol.ErrInternal,
79 })
80 require.Error(t, encodeErr, "a forbidden outcome must not be encodable")
81 return rawServerEnvelope(raw)
82}
83
84// TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown proves a reply
85// that is lost, wrong-typed, uncorrelated, or malformed leaves a typed
86// outcome-unknown: the request was sent, so it is never resent, and the
87// one-shot connection is retired exactly once.
88func TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown(t *testing.T) {
89 tests := []struct {
90 name string
91 transport *cmdTestTransport
92 wantContain string
93 }{
94 {
95 name: "reply lost with EOF",
96 transport: &cmdTestTransport{recvErr: io.EOF},
97 wantContain: "EOF",
98 },
99 {
100 name: "reply lost with a closed pipe",
101 transport: &cmdTestTransport{recvErr: io.ErrClosedPipe},
102 wantContain: "closed pipe",
103 },
104 {
105 name: "wrong reply type",
106 transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.Pong{})},
107 wantContain: "unexpected command reply protocol.Pong",
108 },
109 {
110 name: "wrong reply request ID",
111 transport: &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{
112 RequestID: 99, Outcome: protocol.CommandSucceeded,
113 })},
114 wantContain: "unexpected command reply request ID 99",
115 },
116 {
117 name: "malformed outcome bytes are refused",
118 transport: &cmdTestTransport{recv: malformedCommandOutcomeEnvelope(t)},
119 wantContain: "wire value out of semantic range",
120 },
121 {
122 name: "truncated envelope bytes are refused",
123 transport: &cmdTestTransport{recv: rawServerEnvelope(
124 mustServerEnvelope(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}).Payload[:3])},
125 wantContain: "wire: envelope is truncated",
126 },
127 {
128 name: "definite daemon unknown outcome",
129 transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{
130 Outcome: protocol.CommandOutcomeUnknown, Text: "daemon is shutting down",
131 })},
132 wantContain: "daemon is shutting down",
133 },
134 }
135 for _, tt := range tests {
136 t.Run(tt.name, func(t *testing.T) {
137 err := runCmdReply(context.Background(), tt.transport, nil)
138
139 require.ErrorIs(t, err, errCommandOutcomeUnknown, "a lost or uncorrelated reply is outcome-unknown")
140 require.NotErrorIs(t, err, errCommandNotSent, "the request was sent, so the outcome is not a definite not-sent")
141 require.ErrorContains(t, err, tt.wantContain)
142 require.Equal(t, 3, ExitCode(err))
143 require.Equal(t, 1, countCommandRequests(t, tt.transport), "an indeterminate command must never be resent")
144 require.Equal(t, 1, tt.transport.closeCalls, "the one-shot connection is retired exactly once")
145 })
146 }
147}
148
149// TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend proves a caller
150// canceled while awaiting the reply reports outcome-unknown, sends exactly one
151// request, and retires the connection.
152func TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) {
153 replyStarted := make(chan struct{})
154 recvGate := make(chan struct{})
155 transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate}
156
157 ctx, cancel := context.WithCancel(context.Background())
158 defer cancel()
159 done := make(chan error, 1)
160 go func() { done <- runCmdReply(ctx, transport, nil) }()
161
162 // Wait until the reply read is actually parked, then cancel. The gate makes
163 // entry deterministic without sleeping.
164 <-replyStarted
165 cancel()
166 err := <-done
167 close(recvGate)
168
169 require.ErrorIs(t, err, errCommandOutcomeUnknown)
170 require.Equal(t, 3, ExitCode(err))
171 require.Equal(t, 1, countCommandRequests(t, transport), "a canceled command must never be resent")
172 require.Equal(t, 1, transport.closeCalls, "cancellation after the send must retire the connection")
173}
174
175// TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend proves a request
176// whose result deadline expires while awaiting the reply reports
177// outcome-unknown, sends exactly one request, and retires the connection. The
178// fake clock keeps the deadline deterministic.
179func TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) {
180 timerCh := make(chan time.Time, 1)
181 timer := portsmocks.NewMockTimer(t)
182 timer.EXPECT().C().Return(timerCh).Once()
183 timer.EXPECT().Stop().Return(true).Once()
184 delayCh := make(chan time.Duration, 1)
185 clock := portsmocks.NewMockClock(t)
186 clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) {
187 delayCh <- delay
188 }).Return(timer).Once()
189
190 replyStarted := make(chan struct{})
191 recvGate := make(chan struct{})
192 transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate}
193 done := make(chan error, 1)
194 go func() { done <- runCmdReply(context.Background(), transport, clock) }()
195
196 <-replyStarted
197 require.Equal(t, daemon.CommandRequestTimeout, <-delayCh)
198 timerCh <- time.Time{}
199 err := <-done
200 close(recvGate)
201
202 require.ErrorIs(t, err, errCommandOutcomeUnknown)
203 require.ErrorContains(t, err, daemon.ErrCommandRequestTimeout.Error())
204 require.Equal(t, 3, ExitCode(err))
205 require.Equal(t, 1, countCommandRequests(t, transport), "a timed-out command must never be resent")
206 require.Equal(t, 1, transport.closeCalls, "a result timeout must retire the connection")
207}
208
209// TestRunCmdCancelBeforeSendIsDefiniteNotSent proves a caller already canceled
210// before the request is sent reports a definite not-sent outcome, puts no
211// CommandRequest on the wire, and is not misclassified as unknown.
212func TestRunCmdCancelBeforeSendIsDefiniteNotSent(t *testing.T) {
213 transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
214
215 ctx, cancel := context.WithCancel(context.Background())
216 cancel()
217 err := runCmdReply(ctx, transport, nil)
218
219 require.ErrorIs(t, err, errCommandNotSent)
220 require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a not-sent command has a definite outcome")
221 require.Equal(t, 0, countCommandRequests(t, transport), "a canceled-before-send command must not reach the wire")
222}
223
224// TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError proves a
225// definite daemon outcome is reported with its exact text and exit code, and
226// still sends exactly one request.
227func TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError(t *testing.T) {
228 tests := []struct {
229 name string
230 result protocol.CommandResult
231 wantCode int
232 }{
233 {
234 name: "success",
235 result: protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"},
236 wantCode: 0,
237 },
238 {
239 name: "invalid arguments are a usage error",
240 result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: split-right"},
241 wantCode: 2,
242 },
243 {
244 name: "runtime failure is an exit one failure",
245 result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
246 wantCode: 1,
247 },
248 }
249 for _, tt := range tests {
250 t.Run(tt.name, func(t *testing.T) {
251 out := new(strings.Builder)
252 transport := &cmdTestTransport{recv: mustServerEnvelope(tt.result)}
253 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
254 stdout: out,
255 getenv: func(string) string { return "" },
256 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
257 })
258
259 require.Equal(t, tt.wantCode, ExitCode(err))
260 require.Equal(t, 1, countCommandRequests(t, transport), "a definite outcome sends exactly one request")
261 require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a definite outcome is never reported as unknown")
262 if tt.result.Outcome == protocol.CommandSucceeded {
263 require.NoError(t, err)
264 require.Equal(t, tt.result.Output+"\n", out.String())
265 } else {
266 require.ErrorContains(t, err, tt.result.Text)
267 require.NotErrorIs(t, err, errCommandNotSent)
268 }
269 })
270 }
271}