1package app
2
3import (
4 "context"
5 "errors"
6 "testing"
7
8 "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14)
15
16func TestParseHostArgs(t *testing.T) {
17 tests := []struct {
18 name string
19 args []string
20 wantAction string
21 wantTarget string
22 wantTransport string
23 wantErr string
24 nonUsageErr bool
25 }{
26 {name: "add defaults to quic", args: []string{"add", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "quic"},
27 {name: "add explicit quic", args: []string{"add", "--transport", "quic", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "quic"},
28 {name: "add ssh", args: []string{"add", "--transport", "ssh", "user@arch"}, wantAction: "add", wantTarget: "user@arch", wantTransport: "ssh"},
29 {name: "add ssh equals form", args: []string{"add", "--transport=ssh", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "ssh"},
30 {name: "add flag after target", args: []string{"add", "arch", "--transport", "ssh"}, wantAction: "add", wantTarget: "arch", wantTransport: "ssh"},
31 {name: "add invalid value", args: []string{"add", "--transport", "stdio", "arch"}, wantErr: `want "quic" or "ssh"`},
32 {name: "add invalid equals value", args: []string{"add", "--transport=udp", "arch"}, wantErr: `want "quic" or "ssh"`},
33 {name: "add empty equals value", args: []string{"add", "--transport=", "arch"}, wantErr: `want "quic" or "ssh"`},
34 {name: "add missing value", args: []string{"add", "arch", "--transport"}, wantErr: "requires a value"},
35 {name: "add repeated flag", args: []string{"add", "--transport", "ssh", "--transport", "quic", "arch"}, wantErr: "once"},
36 {name: "add unknown flag", args: []string{"add", "--bogus", "arch"}, wantErr: "unknown flag"},
37 {name: "add missing target", args: []string{"add"}, wantErr: "requires a host target"},
38 {name: "add flag without target", args: []string{"add", "--transport", "ssh"}, wantErr: "requires a host target"},
39 {name: "add extra target", args: []string{"add", "--transport", "ssh", "arch", "extra"}, wantErr: "exactly one host target"},
40 {name: "add invalid target", args: []string{"add", "--transport", "ssh", " bad"}, wantErr: "host", nonUsageErr: true},
41 {name: "rm", args: []string{"rm", "arch"}, wantAction: "rm", wantTarget: "arch"},
42 {name: "rm rejects transport", args: []string{"rm", "--transport", "ssh", "arch"}, wantErr: "unknown flag"},
43 {name: "rm rejects transport after target", args: []string{"rm", "arch", "--transport=ssh"}, wantErr: "exactly one host target"},
44 {name: "rm missing target", args: []string{"rm"}, wantErr: "requires a host target"},
45 {name: "list", args: []string{"list"}, wantAction: "list"},
46 {name: "list rejects transport", args: []string{"list", "--transport", "ssh"}, wantErr: "unknown flag"},
47 {name: "list extra", args: []string{"list", "arch"}, wantErr: "accepts no arguments"},
48 }
49 for _, tt := range tests {
50 t.Run(tt.name, func(t *testing.T) {
51 got, err := parseHostArgs(tt.args)
52 if tt.wantErr != "" {
53 require.Error(t, err)
54 require.Contains(t, err.Error(), tt.wantErr)
55 _, usage := errors.AsType[*usageError](err)
56 require.Equal(t, !tt.nonUsageErr, usage, "error type %T", err)
57 return
58 }
59 require.NoError(t, err)
60 require.Equal(t, kindHost, got.kind)
61 require.Equal(t, tt.wantAction, got.hostAction)
62 require.Equal(t, tt.wantTarget, got.hostTarget)
63 require.Equal(t, tt.wantTransport, got.hostTransport)
64 })
65 }
66}
67
68func TestHostAddPolicyFollowsTransport(t *testing.T) {
69 tests := []struct {
70 name string
71 args []string
72 wantPolicy ports.BrokerPolicy
73 }{
74 {name: "default", args: []string{"add", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportQUIC)},
75 {name: "quic", args: []string{"add", "--transport", "quic", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportQUIC)},
76 {name: "ssh", args: []string{"add", "--transport", "ssh", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportSSH)},
77 }
78 for _, tt := range tests {
79 t.Run(tt.name, func(t *testing.T) {
80 cmd, err := parseHostArgs(tt.args)
81 require.NoError(t, err)
82 service := portsmocks.NewMockBrokerService(t)
83 service.EXPECT().AddHost(mock.Anything, "arch", tt.wantPolicy).Return(domain.RemoteRegistration{}, nil).Once()
84 service.EXPECT().Close().Return(nil).Once()
85 deps := remoteHostDeps{connect: func(context.Context) (ports.BrokerService, error) { return service, nil }}
86 require.NoError(t, runHostCommand(context.Background(), cmd, deps))
87 })
88 }
89}
90
91func TestHostAddReportsTransportConflict(t *testing.T) {
92 // ipcConflict is the shape a conflict has after crossing broker IPC: only
93 // the code survives, not the sentinel.
94 ipcConflict := ports.BrokerError{Code: ports.BrokerErrorHostConflict, Text: "host registration conflict"}
95 storedSSH := ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{
96 {Endpoint: "arch", Policy: remoteBrokerPolicy(hostTransportSSH)},
97 }}
98 storedLegacy := ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{
99 {Endpoint: "arch", Policy: ports.BrokerPolicy{Transport: "stdio"}},
100 }}
101 tests := []struct {
102 name string
103 addErr error
104 snapshot ports.BrokerSnapshot
105 wantHint bool
106 }{
107 {name: "in-process sentinel with other transport", addErr: ports.ErrBrokerHostConflict, snapshot: storedSSH, wantHint: true},
108 {name: "IPC code with other transport", addErr: ipcConflict, snapshot: storedSSH, wantHint: true},
109 {name: "same transport conflicts elsewhere", addErr: ipcConflict, snapshot: ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{
110 {Endpoint: "arch", Policy: remoteBrokerPolicy(hostTransportQUIC)},
111 }}},
112 {name: "host not in snapshot", addErr: ipcConflict, snapshot: ports.BrokerSnapshot{}},
113 {name: "unrecognised stored transport", addErr: ipcConflict, snapshot: storedLegacy},
114 }
115 for _, tt := range tests {
116 t.Run(tt.name, func(t *testing.T) {
117 service := portsmocks.NewMockBrokerService(t)
118 service.EXPECT().AddHost(mock.Anything, "arch", remoteBrokerPolicy(hostTransportQUIC)).Return(domain.RemoteRegistration{}, tt.addErr).Once()
119 service.EXPECT().Snapshot().Return(tt.snapshot).Once()
120 service.EXPECT().Close().Return(nil).Once()
121 deps := remoteHostDeps{connect: func(context.Context) (ports.BrokerService, error) { return service, nil }}
122 err := runHostCommand(context.Background(), command{kind: kindHost, hostAction: hostActionAdd, hostTarget: "arch", hostTransport: hostTransportQUIC}, deps)
123 require.ErrorIs(t, err, tt.addErr)
124 if !tt.wantHint {
125 require.NotContains(t, err.Error(), "vev host rm")
126 return
127 }
128 require.Contains(t, err.Error(), `transport "ssh"`)
129 require.Contains(t, err.Error(), "vev host rm arch")
130 })
131 }
132}