// Tests for `carlos relay`'s client half: the wire shape the five apiSession // methods send (matching internal/console/apirelay_test.go's server side), // and the honesty rules printRelayStatus and printRelayRotate must follow. // // The printers get the most attention here, because `carlos relay status` is // the ONLY sight a member with no box access has of whether their calls can // connect. Every rule pinned below is one where a wrong line would leave them // believing something the console did not say: that a route has the key when // it is waiting on them, that a relay is healthy when it stopped reading five // minutes ago, that a disable is finished when allocations are still live. package main import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) // ---- apiSession.relay* wire shape ------------------------------------------- // TestAPISessionRelayWireShapes pins the path and body of each of the four // app-scoped calls plus rotate. rotate is the one with no account segment at // all — a literal path, because there is nothing per-app about moving a // region's issuer pointer. func TestAPISessionRelayWireShapes(t *testing.T) { var gotPath, gotMethod, gotAuth, gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") body := new(bytes.Buffer) body.ReadFrom(r.Body) gotBody = strings.TrimSpace(body.String()) json.NewEncoder(w).Encode(map[string]any{"app": "meet", "enabled": true}) })) defer ts.Close() sess := newTestSession(ts.URL) a := apiAcct{Seg: "meet-team"} if _, err := sess.relayEnable(context.Background(), a, "meet", nil); err != nil { t.Fatalf("relayEnable: %v", err) } if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/meet-team/meet/relay" { t.Fatalf("enable = %s %s", gotMethod, gotPath) } // No regions named sends the literal {} the door documents as its // default, never {"regions":null}. if gotBody != "{}" { t.Fatalf("enable body = %q, want {}", gotBody) } if gotAuth != "Bearer cli-token" { t.Fatalf("auth = %q", gotAuth) } if _, err := sess.relayEnable(context.Background(), a, "meet", []string{"eu-west-1"}); err != nil { t.Fatalf("relayEnable(regions): %v", err) } if gotBody != `{"regions":["eu-west-1"]}` { t.Fatalf("enable body with regions = %q", gotBody) } if _, err := sess.relayGet(context.Background(), a, "meet"); err != nil { t.Fatalf("relayGet: %v", err) } if gotMethod != http.MethodGet || gotPath != "/api/cli/apps/meet-team/meet/relay" { t.Fatalf("status = %s %s", gotMethod, gotPath) } if _, err := sess.relayDisable(context.Background(), a, "meet"); err != nil { t.Fatalf("relayDisable: %v", err) } if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/meet-team/meet/relay/disable" { t.Fatalf("disable = %s %s", gotMethod, gotPath) } if _, err := sess.relayAdopted(context.Background(), a, "meet", "x.meet.example"); err != nil { t.Fatalf("relayAdopted: %v", err) } if gotPath != "/api/cli/apps/meet-team/meet/relay/adopted" { t.Fatalf("adopted path = %s", gotPath) } if gotBody != `{"host":"x.meet.example"}` { t.Fatalf("adopted body = %q", gotBody) } if _, err := sess.relayRotate(context.Background(), "eu-west-1"); err != nil { t.Fatalf("relayRotate: %v", err) } if gotPath != "/api/cli/relay/rotate" { t.Fatalf("rotate path = %s, want the literal deployment-wide door", gotPath) } if gotBody != `{"region":"eu-west-1"}` { t.Fatalf("rotate body = %q", gotBody) } } // TestAPISessionRelayRotateNotFoundNamesTheOperatorList: the rotate door // answers a non-operator with the mux's own byte-identical 404, which is // deliberate — a 403 would confirm to any token holder that this deployment // runs a relay. So the CLI cannot tell "not allowed" from "no such door", and // the message must say BOTH rather than fall back to the generic membership // advice, which names --account and --app: two flags this verb does not have, // sending an operator to check something they cannot even type. func TestAPISessionRelayRotateNotFoundNamesTheOperatorList(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() _, err := newTestSession(ts.URL).relayRotate(context.Background(), "eu-west-1") if err == nil { t.Fatal("rotate against a 404 door returned no error") } if !strings.Contains(err.Error(), "CARLOS_RELAY_OPERATORS") { t.Fatalf("error = %q, want the operator list named", err) } if strings.Contains(err.Error(), "--account") || strings.Contains(err.Error(), "--app") { t.Fatalf("error = %q, want no advice about flags this verb does not have", err) } } // TestAPISessionRelayServerDiagnosisSurvives: a 404 that DOES carry a JSON // error is a real server sentence ("the relay has never been enabled...") and // must reach the member verbatim, not be rewritten into the generic one. func TestAPISessionRelayServerDiagnosisSurvives(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]any{"error": "the relay has never been enabled for this app"}) })) defer ts.Close() _, err := newTestSession(ts.URL).relayGet(context.Background(), apiAcct{Seg: "t"}, "meet") if err == nil || !strings.Contains(err.Error(), "never been enabled") { t.Fatalf("relayGet 404 = %v, want the console's own text", err) } } // ---- printRelayStatus -------------------------------------------------------- // // Driven against literal replies, no stub server: the wording rules are the // thing under test, and they must not depend on the wire plumbing above. func TestPrintRelayStatus(t *testing.T) { healthy := apiRelayReply{ App: "meet", Enabled: true, Incarnation: "0123456789abcdef", Regions: []string{"eu-west-1"}, Op: 3, Phase: "done", Issued: map[string]string{"eu-west-1": "abc:meet:0123456789abcdef:k1"}, RegionStatus: []apiRelayRegion{{ Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "12s", Current: true, LeaseLive: true, Listeners: []string{"udp:3478"}, Kids: []string{"k1"}, }}, } cases := []struct { name string res apiRelayReply want []string wantNot []string modifier func(*apiRelayReply) }{ { name: "healthy", res: healthy, want: []string{"relay for meet", "enabled in eu-west-1", "0123456789abcdef", "op 3", "done", "issued eu-west-1: abc:meet:0123456789abcdef:k1", "relay relay-1 in eu-west-1: heartbeat 12s", "udp:3478", "masters k1", "lease live"}, // A healthy relay must never be described as stale or as holding // an old record — the two words a member acts on. wantNot: []string{"STALE", "older record", "disable pending"}, }, { // The operator's mid-rotation question, and the only place the // CLI can answer it: when may the old key leave this box's // /etc/carlos/relay-masters (discussion #120). name: "a retiring master names when it may be dropped", res: healthy, modifier: func(r *apiRelayReply) { r.RegionStatus = []apiRelayRegion{{ Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "12s", Current: true, LeaseLive: true, Listeners: []string{"udp:3478"}, Kids: []string{"k1", "k2"}, Retiring: map[string]string{"k2": "2026-09-10T12:00:00Z", "k1": "2026-09-09T12:00:00Z"}, }} }, // Two of them, deliberately seeded out of order: the kids are // sorted, and the retiring clause uses the same tight separator // the masters list does, so its items cannot be misread as // further clauses of the line. want: []string{"masters k1,k2", "retiring k1 after 2026-09-09T12:00:00Z,k2 after 2026-09-10T12:00:00Z"}, }, { name: "stale names the relay's own reason", res: healthy, modifier: func(r *apiRelayReply) { r.RegionStatus = []apiRelayRegion{{ Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "6m0s", Current: false, LeaseLive: true, Stale: true, Kids: []string{"k1"}, AuthorityError: "get control/relay.json: 503", }} }, want: []string{"STALE", "holding an older record", "6m0s", "authority", "503"}, // Still inside its fifteen-minute lease, so it IS still serving: // saying otherwise would send someone hunting a dead box. wantNot: []string{"no longer serving"}, }, { name: "lease expired", res: healthy, modifier: func(r *apiRelayReply) { r.RegionStatus = []apiRelayRegion{{ Region: "eu-west-1", Box: "relay-1", HeartbeatAge: "40m0s", Current: true, LeaseLive: false, Stale: false, }} }, want: []string{"lease expired", "no longer serving"}, }, { name: "no heartbeat at all", res: healthy, modifier: func(r *apiRelayReply) { r.RegionStatus = nil }, want: []string{"no relay in eu-west-1 has heartbeated at all", "will not connect"}, wantNot: []string{"lease live"}, }, { name: "disable pending", res: healthy, modifier: func(r *apiRelayReply) { r.Enabled, r.Op, r.Phase = false, 4, "done" r.Issued = nil r.DisablePending = []string{"eu-west-1"} }, // A disabled record still shows its operation and phase: a // disable stuck at phase "config" is not the same state as one // that finished, and "not enabled" alone would hide the // difference. want: []string{"disabled (op 4, done)", "disable pending on eu-west-1", "next authority read, within a minute"}, wantNot: []string{"revoked"}, }, { name: "revoked", res: healthy, modifier: func(r *apiRelayReply) { r.Enabled, r.Op = false, 4 r.Issued = nil }, want: []string{"revoked: every relay has read a record without this app"}, wantNot: []string{"disable pending"}, }, { name: "never enabled", res: apiRelayReply{App: "meet"}, want: []string{"not enabled", "carlos relay enable --app meet"}, // Nothing was ever issued, so there is no operation to report. wantNot: []string{"op 0", "revoked"}, }, { name: "route deliveries", res: healthy, modifier: func(r *apiRelayReply) { r.Routes = []apiRelayRoute{ {Host: "a.meet", Shape: "unit", Delivery: "written, awaiting delivery"}, {Host: "b.meet", Shape: "socket-exec", Delivery: "written, awaiting delivery"}, {Host: "c.meet", Shape: "tcp-exec-file", Delivery: "file delivered, awaiting `carlos relay adopted --host c.meet` after you restart it"}, {Host: "d.meet", Shape: "no-delivery", Delivery: "no config delivery: relay will not reach this route; opt it into exec_delivery file or move it to a unit"}, {Host: "e.meet", Shape: "tcp-exec-file", Cutover: true, AckedAt: "2026-09-07T10:00:00Z", Delivery: "adopted by member at 2026-09-07T10:00:00Z"}, // f, g, h: the three states Task 12's config-ack adds for a // unit or socket-exec route (internal/console/apirelay.go's // relayMatchingAck) — printRelayStatus computes none of this, // it only prints whatever Delivery sentence it was handed, so // these pin that the sentences flow through unmangled. {Host: "f.meet", Shape: "unit", ConfigAckAt: "2026-09-07T10:05:00Z", Delivery: "adopted at 2026-09-07T10:05:00Z, restarted"}, {Host: "g.meet", Shape: "unit", Delivery: "restart failed: systemctl try-restart carlos-app@g.meet: exit status 1"}, {Host: "h.meet", Shape: "unit", Delivery: "hibernating, delivered on wake"}, } }, want: []string{ "route a.meet (unit): written, awaiting delivery", "route b.meet (socket-exec): written", "route c.meet (tcp-exec-file): file delivered, awaiting `carlos relay adopted --host c.meet`", "route d.meet (no-delivery): no config delivery", "route e.meet (tcp-exec-file): adopted by member at 2026-09-07T10:00:00Z", "route f.meet (unit): adopted at 2026-09-07T10:05:00Z, restarted", "route g.meet (unit): restart failed: systemctl try-restart carlos-app@g.meet: exit status 1", "route h.meet (unit): hibernating, delivered on wake", }, // The lesson restart.go paid for: never say a box has acted. wantNot: []string{"no routes yet"}, }, { name: "enabled with no routes", res: healthy, want: []string{"no routes yet"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { res := tc.res if tc.modifier != nil { tc.modifier(&res) } var buf bytes.Buffer printRelayStatus(&buf, res) out := buf.String() for _, want := range tc.want { if !strings.Contains(out, want) { t.Fatalf("missing %q:\n%s", want, out) } } for _, no := range tc.wantNot { if strings.Contains(out, no) { t.Fatalf("must not contain %q:\n%s", no, out) } } }) } } // TestPrintRelayStatusNeverClaimsDelivery is the output-honesty rule of this // whole file in one assertion: given only "written, awaiting delivery" — no // config-ack, the state before Task 12's per-route ack (internal/appconfig/ // configack.go) exists for a route — printRelayStatus must not claim a // running process has picked anything up. It is a pure function of // res.Routes[i].Delivery either way: what earns the fuller "adopted at //