1package ports
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/protocol"
9 "github.com/bnema/vev/internal/protocol/catalogue"
10 "github.com/stretchr/testify/require"
11)
12
13func TestBrokerDaemonObservationValidate(t *testing.T) {
14 observed := func(o BrokerDaemonObservation) BrokerDaemonObservation {
15 o.Identity = BrokerDaemonIdentity("authed-daemon")
16 o.Incarnation = BrokerDaemonIncarnation{9}
17 o.ProtocolVersion = protocol.Version
18 o.Capabilities = protocol.CapabilityResume | protocol.CapabilityUDP
19 return o
20 }
21 tests := []struct {
22 name string
23 mutate func(*BrokerDaemonObservation)
24 valid bool
25 }{
26 {name: "unobserved remote daemon", mutate: func(*BrokerDaemonObservation) {}, valid: true},
27 {name: "unknown availability", mutate: func(o *BrokerDaemonObservation) {
28 o.Availability = domain.RemoteAvailabilityUnknown
29 }, valid: true},
30 {name: "observed daemon", mutate: func(o *BrokerDaemonObservation) { *o = observed(*o) }, valid: true},
31 {name: "observed daemon without capabilities", mutate: func(o *BrokerDaemonObservation) {
32 *o = observed(*o)
33 o.Capabilities = 0
34 }, valid: true},
35 {name: "observed daemon reports a different protocol version", mutate: func(o *BrokerDaemonObservation) {
36 *o = observed(*o)
37 o.ProtocolVersion = protocol.Version + 1
38 o.Availability = domain.RemoteAvailabilityIncompatible
39 }, valid: true},
40 {name: "observed identity without protocol version", mutate: func(o *BrokerDaemonObservation) {
41 *o = observed(*o)
42 o.ProtocolVersion = 0
43 }, valid: false},
44 {name: "identity without incarnation", mutate: func(o *BrokerDaemonObservation) {
45 o.Identity = BrokerDaemonIdentity("authed-daemon")
46 }, valid: false},
47 {name: "incarnation without identity", mutate: func(o *BrokerDaemonObservation) {
48 o.Incarnation = BrokerDaemonIncarnation{9}
49 }, valid: false},
50 {name: "empty display origin", mutate: func(o *BrokerDaemonObservation) {
51 o.DisplayOrigin = ""
52 }, valid: false},
53 {name: "control characters in display origin", mutate: func(o *BrokerDaemonObservation) {
54 o.DisplayOrigin = "bad\x1border"
55 }, valid: false},
56 {name: "oversized display origin", mutate: func(o *BrokerDaemonObservation) {
57 o.DisplayOrigin = strings.Repeat("a", BrokerMaxDisplayOriginBytes+1)
58 }, valid: false},
59 {name: "missing policy", mutate: func(o *BrokerDaemonObservation) {
60 o.Policy = BrokerPolicy{}
61 }, valid: false},
62 {name: "zero availability is never invented", mutate: func(o *BrokerDaemonObservation) {
63 o.Availability = 0
64 }, valid: false},
65 {name: "failure kind past the closed range", mutate: func(o *BrokerDaemonObservation) {
66 o.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1
67 }, valid: false},
68 {name: "empty inventory is known inventory", mutate: func(o *BrokerDaemonObservation) {
69 o.InventoryKnown = true
70 }, valid: true},
71 {name: "sessions without known inventory", mutate: func(o *BrokerDaemonObservation) {
72 o.Sessions = []catalogue.RemoteCatalogSession{{
73 LifecycleID: testBrokerLifecycle(),
74 Name: "work",
75 State: catalogue.RemoteCatalogSessionUp,
76 Tabs: []catalogue.RemoteCatalogTab{},
77 }}
78 }, valid: false},
79 {name: "local daemon without remote authority", mutate: func(o *BrokerDaemonObservation) {
80 *o = testLocalBrokerObservation()
81 }, valid: true},
82 {name: "local daemon carrying an endpoint", mutate: func(o *BrokerDaemonObservation) {
83 *o = testLocalBrokerObservation()
84 o.Endpoint = "user@arch"
85 }, valid: false},
86 {name: "local daemon carrying a registration", mutate: func(o *BrokerDaemonObservation) {
87 *o = testLocalBrokerObservation()
88 o.Registration = testBrokerRegistration("user@arch", 1, 1)
89 }, valid: false},
90 {name: "remote daemon missing registration", mutate: func(o *BrokerDaemonObservation) {
91 o.Registration = domain.RemoteRegistration{}
92 }, valid: false},
93 {name: "endpoint registration mismatch", mutate: func(o *BrokerDaemonObservation) {
94 o.Registration = testBrokerRegistration("user@other", 1, 1)
95 }, valid: false},
96 }
97 for _, tc := range tests {
98 t.Run(tc.name, func(t *testing.T) {
99 obs := testBrokerObservation("user@arch")
100 tc.mutate(&obs)
101 if (obs.Validate() == nil) != tc.valid {
102 t.Fatalf("Validate() = %v, want valid %t", obs.Validate(), tc.valid)
103 }
104 })
105 }
106}
107
108// TestBrokerDaemonObservationValidateDisplayOrigin pins the display-origin
109// bound and character rules with exact refusals: a bidi control or a Unicode
110// line separator is refused as disallowed text, an origin at the byte bound is
111// accepted, and one byte over is refused.
112func TestBrokerDaemonObservationValidateDisplayOrigin(t *testing.T) {
113 tests := []struct {
114 name string
115 origin string
116 wantErrContains string
117 }{
118 {
119 name: "bidi control is refused",
120 origin: "user\u202earch",
121 wantErrContains: "display origin contains disallowed characters",
122 },
123 {
124 name: "line separator is refused",
125 origin: "user@arch\u2028",
126 wantErrContains: "display origin contains disallowed characters",
127 },
128 {
129 name: "origin at the byte bound is accepted",
130 origin: strings.Repeat("a", BrokerMaxDisplayOriginBytes),
131 },
132 {
133 name: "origin one byte over the bound is refused",
134 origin: strings.Repeat("a", BrokerMaxDisplayOriginBytes+1),
135 wantErrContains: "display origin too long",
136 },
137 }
138 for _, tc := range tests {
139 t.Run(tc.name, func(t *testing.T) {
140 obs := testBrokerObservation("user@arch")
141 obs.DisplayOrigin = tc.origin
142 err := obs.Validate()
143 if tc.wantErrContains != "" {
144 require.ErrorContains(t, err, tc.wantErrContains)
145 return
146 }
147 require.NoError(t, err)
148 })
149 }
150}
151
152func TestBrokerDaemonObservationCloneIsDefensive(t *testing.T) {
153 original := testBrokerObservation("user@arch")
154 original.InventoryKnown = true
155 original.Sessions = []catalogue.RemoteCatalogSession{{
156 LifecycleID: testBrokerLifecycle(),
157 Name: "work",
158 State: catalogue.RemoteCatalogSessionUp,
159 Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell"}},
160 }}
161 cloned := original.Clone()
162 cloned.Sessions[0].Name = "mutated"
163 cloned.Sessions[0].Tabs[0].ID = "mutated"
164 if original.Sessions[0].Name != "work" || original.Sessions[0].Tabs[0].ID != "tab-1" {
165 t.Fatal("Clone() shares memory with the original")
166 }
167 if (BrokerDaemonObservation{}).Clone().Sessions != nil {
168 t.Fatal("Clone() of an empty observation must keep a nil session slice")
169 }
170}