carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Tests for `carlos relay`'s client half: the wire shape the five apiSession
2// methods send (matching internal/console/apirelay_test.go's server side),
3// and the honesty rules printRelayStatus and printRelayRotate must follow.
4//
5// The printers get the most attention here, because `carlos relay status` is
6// the ONLY sight a member with no box access has of whether their calls can
7// connect. Every rule pinned below is one where a wrong line would leave them
8// believing something the console did not say: that a route has the key when
9// it is waiting on them, that a relay is healthy when it stopped reading five
10// minutes ago, that a disable is finished when allocations are still live.
11package main
12
13import (
14 "bytes"
15 "context"
16 "encoding/json"
17 "net/http"
18 "net/http/httptest"
19 "strings"
20 "testing"
21)
22
23// ---- apiSession.relay* wire shape -------------------------------------------
24
25// TestAPISessionRelayWireShapes pins the path and body of each of the four
26// app-scoped calls plus rotate. rotate is the one with no account segment at
27// all — a literal path, because there is nothing per-app about moving a
28// region's issuer pointer.
29func TestAPISessionRelayWireShapes(t *testing.T) {
30 var gotPath, gotMethod, gotAuth, gotBody string
31 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32 gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
33 body := new(bytes.Buffer)
34 body.ReadFrom(r.Body)
35 gotBody = strings.TrimSpace(body.String())
36 json.NewEncoder(w).Encode(map[string]any{"app": "meet", "enabled": true})
37 }))
38 defer ts.Close()
39 sess := newTestSession(ts.URL)
40 a := apiAcct{Seg: "meet-team"}
41
42 if _, err := sess.relayEnable(context.Background(), a, "meet", nil); err != nil {
43 t.Fatalf("relayEnable: %v", err)
44 }
45 if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/meet-team/meet/relay" {
46 t.Fatalf("enable = %s %s", gotMethod, gotPath)
47 }
48 // No regions named sends the literal {} the door documents as its
49 // default, never {"regions":null}.
50 if gotBody != "{}" {
51 t.Fatalf("enable body = %q, want {}", gotBody)
52 }
53 if gotAuth != "Bearer cli-token" {
54 t.Fatalf("auth = %q", gotAuth)
55 }
56
57 if _, err := sess.relayEnable(context.Background(), a, "meet", []string{"eu-west-1"}); err != nil {
58 t.Fatalf("relayEnable(regions): %v", err)
59 }
60 if gotBody != `{"regions":["eu-west-1"]}` {
61 t.Fatalf("enable body with regions = %q", gotBody)
62 }
63
64 if _, err := sess.relayGet(context.Background(), a, "meet"); err != nil {
65 t.Fatalf("relayGet: %v", err)
66 }
67 if gotMethod != http.MethodGet || gotPath != "/api/cli/apps/meet-team/meet/relay" {
68 t.Fatalf("status = %s %s", gotMethod, gotPath)
69 }
70
71 if _, err := sess.relayDisable(context.Background(), a, "meet"); err != nil {
72 t.Fatalf("relayDisable: %v", err)
73 }
74 if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/meet-team/meet/relay/disable" {
75 t.Fatalf("disable = %s %s", gotMethod, gotPath)
76 }
77
78 if _, err := sess.relayAdopted(context.Background(), a, "meet", "x.meet.example"); err != nil {
79 t.Fatalf("relayAdopted: %v", err)
80 }
81 if gotPath != "/api/cli/apps/meet-team/meet/relay/adopted" {
82 t.Fatalf("adopted path = %s", gotPath)
83 }
84 if gotBody != `{"host":"x.meet.example"}` {
85 t.Fatalf("adopted body = %q", gotBody)
86 }
87
88 if _, err := sess.relayRotate(context.Background(), "eu-west-1"); err != nil {
89 t.Fatalf("relayRotate: %v", err)
90 }
91 if gotPath != "/api/cli/relay/rotate" {
92 t.Fatalf("rotate path = %s, want the literal deployment-wide door", gotPath)
93 }
94 if gotBody != `{"region":"eu-west-1"}` {
95 t.Fatalf("rotate body = %q", gotBody)
96 }
97}
98
99// TestAPISessionRelayRotateNotFoundNamesTheOperatorList: the rotate door
100// answers a non-operator with the mux's own byte-identical 404, which is
101// deliberate — a 403 would confirm to any token holder that this deployment
102// runs a relay. So the CLI cannot tell "not allowed" from "no such door", and
103// the message must say BOTH rather than fall back to the generic membership
104// advice, which names --account and --app: two flags this verb does not have,
105// sending an operator to check something they cannot even type.
106func TestAPISessionRelayRotateNotFoundNamesTheOperatorList(t *testing.T) {
107 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
108 http.NotFound(w, r)
109 }))
110 defer ts.Close()
111
112 _, err := newTestSession(ts.URL).relayRotate(context.Background(), "eu-west-1")
113 if err == nil {
114 t.Fatal("rotate against a 404 door returned no error")
115 }
116 if !strings.Contains(err.Error(), "CARLOS_RELAY_OPERATORS") {
117 t.Fatalf("error = %q, want the operator list named", err)
118 }
119 if strings.Contains(err.Error(), "--account") || strings.Contains(err.Error(), "--app") {
120 t.Fatalf("error = %q, want no advice about flags this verb does not have", err)
121 }
122}
123
124// TestAPISessionRelayServerDiagnosisSurvives: a 404 that DOES carry a JSON
125// error is a real server sentence ("the relay has never been enabled...") and
126// must reach the member verbatim, not be rewritten into the generic one.
127func TestAPISessionRelayServerDiagnosisSurvives(t *testing.T) {
128 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
129 w.WriteHeader(http.StatusNotFound)
130 json.NewEncoder(w).Encode(map[string]any{"error": "the relay has never been enabled for this app"})
131 }))
132 defer ts.Close()
133
134 _, err := newTestSession(ts.URL).relayGet(context.Background(), apiAcct{Seg: "t"}, "meet")
135 if err == nil || !strings.Contains(err.Error(), "never been enabled") {
136 t.Fatalf("relayGet 404 = %v, want the console's own text", err)
137 }
138}
139
140// ---- printRelayStatus --------------------------------------------------------
141//
142// Driven against literal replies, no stub server: the wording rules are the
143// thing under test, and they must not depend on the wire plumbing above.
144
145func TestPrintRelayStatus(t *testing.T) {
146 healthy := apiRelayReply{
147 App: "meet", Enabled: true, Incarnation: "0123456789abcdef",
148 Regions: []string{"eu-west-1"}, Op: 3, Phase: "done",
149 Issued: map[string]string{"eu-west-1": "abc:meet:0123456789abcdef:k1"},
150 RegionStatus: []apiRelayRegion{{
151 Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "12s", Current: true,
152 LeaseLive: true, Listeners: []string{"udp:3478"}, Kids: []string{"k1"},
153 }},
154 }
155
156 cases := []struct {
157 name string
158 res apiRelayReply
159 want []string
160 wantNot []string
161 modifier func(*apiRelayReply)
162 }{
163 {
164 name: "healthy",
165 res: healthy,
166 want: []string{"relay for meet", "enabled in eu-west-1", "0123456789abcdef",
167 "op 3", "done", "issued eu-west-1: abc:meet:0123456789abcdef:k1",
168 "relay relay-1 in eu-west-1: heartbeat 12s", "udp:3478", "masters k1", "lease live"},
169 // A healthy relay must never be described as stale or as holding
170 // an old record — the two words a member acts on.
171 wantNot: []string{"STALE", "older record", "disable pending"},
172 },
173 {
174 // The operator's mid-rotation question, and the only place the
175 // CLI can answer it: when may the old key leave this box's
176 // /etc/carlos/relay-masters (discussion #120).
177 name: "a retiring master names when it may be dropped",
178 res: healthy,
179 modifier: func(r *apiRelayReply) {
180 r.RegionStatus = []apiRelayRegion{{
181 Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "12s", Current: true,
182 LeaseLive: true, Listeners: []string{"udp:3478"}, Kids: []string{"k1", "k2"},
183 Retiring: map[string]string{"k2": "2026-09-10T12:00:00Z", "k1": "2026-09-09T12:00:00Z"},
184 }}
185 },
186 // Two of them, deliberately seeded out of order: the kids are
187 // sorted, and the retiring clause uses the same tight separator
188 // the masters list does, so its items cannot be misread as
189 // further clauses of the line.
190 want: []string{"masters k1,k2", "retiring k1 after 2026-09-09T12:00:00Z,k2 after 2026-09-10T12:00:00Z"},
191 },
192 {
193 name: "stale names the relay's own reason",
194 res: healthy,
195 modifier: func(r *apiRelayReply) {
196 r.RegionStatus = []apiRelayRegion{{
197 Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "6m0s", Current: false,
198 LeaseLive: true, Stale: true, Kids: []string{"k1"},
199 AuthorityError: "get control/relay.json: 503",
200 }}
201 },
202 want: []string{"STALE", "holding an older record", "6m0s", "authority", "503"},
203 // Still inside its fifteen-minute lease, so it IS still serving:
204 // saying otherwise would send someone hunting a dead box.
205 wantNot: []string{"no longer serving"},
206 },
207 {
208 name: "lease expired",
209 res: healthy,
210 modifier: func(r *apiRelayReply) {
211 r.RegionStatus = []apiRelayRegion{{
212 Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "40m0s",
213 Current: true, LeaseLive: false, Stale: false,
214 }}
215 },
216 want: []string{"lease expired", "no longer serving"},
217 },
218 {
219 name: "no heartbeat at all",
220 res: healthy,
221 modifier: func(r *apiRelayReply) {
222 r.RegionStatus = nil
223 },
224 want: []string{"no relay in eu-west-1 has heartbeated at all", "will not connect"},
225 wantNot: []string{"lease live"},
226 },
227 {
228 name: "disable pending",
229 res: healthy,
230 modifier: func(r *apiRelayReply) {
231 r.Enabled, r.Op, r.Phase = false, 4, "done"
232 r.Issued = nil
233 r.DisablePending = []string{"eu-west-1"}
234 },
235 // A disabled record still shows its operation and phase: a
236 // disable stuck at phase "config" is not the same state as one
237 // that finished, and "not enabled" alone would hide the
238 // difference.
239 want: []string{"disabled (op 4, done)", "disable pending on eu-west-1",
240 "next authority read, within a minute"},
241 wantNot: []string{"revoked"},
242 },
243 {
244 name: "revoked",
245 res: healthy,
246 modifier: func(r *apiRelayReply) {
247 r.Enabled, r.Op = false, 4
248 r.Issued = nil
249 },
250 want: []string{"revoked: every relay has read a record without this app"},
251 wantNot: []string{"disable pending"},
252 },
253 {
254 name: "never enabled",
255 res: apiRelayReply{App: "meet"},
256 want: []string{"not enabled", "carlos relay enable --app meet"},
257 // Nothing was ever issued, so there is no operation to report.
258 wantNot: []string{"op 0", "revoked"},
259 },
260 {
261 name: "route deliveries",
262 res: healthy,
263 modifier: func(r *apiRelayReply) {
264 r.Routes = []apiRelayRoute{
265 {Host: "a.meet", Shape: "unit", Delivery: "written, awaiting delivery"},
266 {Host: "b.meet", Shape: "socket-exec", Delivery: "written, awaiting delivery"},
267 {Host: "c.meet", Shape: "tcp-exec-file",
268 Delivery: "file delivered, awaiting `carlos relay adopted --host c.meet` after you restart it"},
269 {Host: "d.meet", Shape: "no-delivery",
270 Delivery: "no config delivery: relay will not reach this route; opt it into exec_delivery file or move it to a unit"},
271 {Host: "e.meet", Shape: "tcp-exec-file", Cutover: true,
272 AckedAt: "2026-09-07T10:00:00Z", Delivery: "adopted by member at 2026-09-07T10:00:00Z"},
273 // f, g, h: the three states Task 12's config-ack adds for a
274 // unit or socket-exec route (internal/console/apirelay.go's
275 // relayMatchingAck) — printRelayStatus computes none of this,
276 // it only prints whatever Delivery sentence it was handed, so
277 // these pin that the sentences flow through unmangled.
278 {Host: "f.meet", Shape: "unit", ConfigAckAt: "2026-09-07T10:05:00Z",
279 Delivery: "adopted at 2026-09-07T10:05:00Z, restarted"},
280 {Host: "g.meet", Shape: "unit",
281 Delivery: "restart failed: systemctl try-restart carlos-app@g.meet: exit status 1"},
282 {Host: "h.meet", Shape: "unit", Delivery: "hibernating, delivered on wake"},
283 }
284 },
285 want: []string{
286 "route a.meet (unit): written, awaiting delivery",
287 "route b.meet (socket-exec): written",
288 "route c.meet (tcp-exec-file): file delivered, awaiting `carlos relay adopted --host c.meet`",
289 "route d.meet (no-delivery): no config delivery",
290 "route e.meet (tcp-exec-file): adopted by member at 2026-09-07T10:00:00Z",
291 "route f.meet (unit): adopted at 2026-09-07T10:05:00Z, restarted",
292 "route g.meet (unit): restart failed: systemctl try-restart carlos-app@g.meet: exit status 1",
293 "route h.meet (unit): hibernating, delivered on wake",
294 },
295 // The lesson restart.go paid for: never say a box has acted.
296 wantNot: []string{"no routes yet"},
297 },
298 {
299 name: "enabled with no routes",
300 res: healthy,
301 want: []string{"no routes yet"},
302 },
303 }
304
305 for _, tc := range cases {
306 t.Run(tc.name, func(t *testing.T) {
307 res := tc.res
308 if tc.modifier != nil {
309 tc.modifier(&res)
310 }
311 var buf bytes.Buffer
312 printRelayStatus(&buf, res)
313 out := buf.String()
314 for _, want := range tc.want {
315 if !strings.Contains(out, want) {
316 t.Fatalf("missing %q:\n%s", want, out)
317 }
318 }
319 for _, no := range tc.wantNot {
320 if strings.Contains(out, no) {
321 t.Fatalf("must not contain %q:\n%s", no, out)
322 }
323 }
324 })
325 }
326}
327
328// TestPrintRelayStatusNeverClaimsDelivery is the output-honesty rule of this
329// whole file in one assertion: given only "written, awaiting delivery" — no
330// config-ack, the state before Task 12's per-route ack (internal/appconfig/
331// configack.go) exists for a route — printRelayStatus must not claim a
332// running process has picked anything up. It is a pure function of
333// res.Routes[i].Delivery either way: what earns the fuller "adopted at
334// <time>, restarted" sentence is relayReply's own config-ack matching
335// (internal/console/apirelay.go), not anything this function decides.
336func TestPrintRelayStatusNeverClaimsDelivery(t *testing.T) {
337 var buf bytes.Buffer
338 printRelayStatus(&buf, apiRelayReply{
339 App: "meet", Enabled: true, Incarnation: "0123456789abcdef",
340 Regions: []string{"eu-west-1"}, Phase: "done",
341 Routes: []apiRelayRoute{{Host: "a.meet", Shape: "unit", Delivery: "written, awaiting delivery"}},
342 })
343 out := strings.ToLower(buf.String())
344 for _, mustNot := range []string{"is using", "now running the", "has the key", "picked it up"} {
345 if strings.Contains(out, mustNot) {
346 t.Fatalf("printRelayStatus over-claims delivery (%q):\n%s", mustNot, buf.String())
347 }
348 }
349}
350
351// ---- printRelayRotate --------------------------------------------------------
352
353// TestPrintRelayRotate: the operator's answer has to carry the retirement
354// deadline, because retiring the old master early kills credentials still
355// inside their own four-hour lifetime — and nothing else in this system will
356// remind them.
357func TestPrintRelayRotate(t *testing.T) {
358 var buf bytes.Buffer
359 printRelayRotate(&buf, apiRelayRotateReply{
360 Region: "eu-west-1", Kid: "k2", Apps: 7,
361 RetireNoEarlierThan: "2026-09-08T16:00:00Z",
362 })
363 out := buf.String()
364 for _, want := range []string{"rotated eu-west-1", "kid k2", "7 app(s) re-issued",
365 "no earlier than 2026-09-08T16:00:00Z", "/etc/carlos/relay-masters"} {
366 if !strings.Contains(out, want) {
367 t.Fatalf("printRelayRotate missing %q:\n%s", want, out)
368 }
369 }
370}
371