| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | "github.com/carlosframework/platform/internal/status" |
| 14 | ) |
| 15 | |
| 16 | // seedLayoutBox writes a box snapshot carrying a layout report at the given |
| 17 | // store and label — the console-side fixture for a door that reads |
| 18 | // status.LoadBoxes AND the customer-fleet boxes apisystemupdate.go's |
| 19 | // systemBoxes already knows how to find (amendment R15, R21). |
| 20 | func seedLayoutBox(t *testing.T, store release.Store, label string, at time.Time, report *status.LayoutReport) { |
| 21 | t.Helper() |
| 22 | b := status.CollectBox(nil, status.Probes{Release: "v1", Layout: report}, at) |
| 23 | b.Label = label |
| 24 | if err := status.WriteBox(context.Background(), store, b); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | // TestAPIStatusLayoutGate: operator only, the identical posture |
| 30 | // handleAPIStatusInstances has (apistatusinstances.go:91-104) — a |
| 31 | // non-operator gets the byte-identical 404 an unknown route gives, so the |
| 32 | // refusal names neither the rows nor whether the caller holds the bit. |
| 33 | func TestAPIStatusLayoutGate(t *testing.T) { |
| 34 | fx := newConsoleFixture(t) |
| 35 | token := cliToken(t, fx, "paul@keymail.dev", "code-apistatuslayout") |
| 36 | ghostStatus, ghostBody := getBearer(t, fx.ts.URL+"/this-api-route-does-not-exist", token) |
| 37 | |
| 38 | if status, _ := getBearer(t, fx.ts.URL+"/api/cli/status/layout", ""); status != http.StatusUnauthorized { |
| 39 | t.Fatalf("unauthenticated = %d; want the CLI door's 401", status) |
| 40 | } |
| 41 | if status, body := getBearer(t, fx.ts.URL+"/api/cli/status/layout", token); status != ghostStatus || string(body) != string(ghostBody) { |
| 42 | t.Fatalf("non-operator = %d %q; want the ghost 404 (%d %q)", status, body, ghostStatus, ghostBody) |
| 43 | } |
| 44 | |
| 45 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | status, body := getBearer(t, fx.ts.URL+"/api/cli/status/layout", token) |
| 49 | if status != http.StatusOK { |
| 50 | t.Fatalf("operator = %d %q", status, body) |
| 51 | } |
| 52 | var out struct { |
| 53 | Boxes []apiStatusLayoutBox `json:"boxes"` |
| 54 | } |
| 55 | if err := json.Unmarshal(body, &out); err != nil { |
| 56 | t.Fatalf("unreadable: %v: %s", err, body) |
| 57 | } |
| 58 | if out.Boxes == nil { |
| 59 | t.Error("boxes decoded as null; want an empty array") |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // TestAPIStatusLayoutListsBoxesRowsAndStale pins the door's shape: one |
| 64 | // entry per box that has reported a layout, its rows, and Stale computed |
| 65 | // from status.BoxStale the same way every other private reader gets it — |
| 66 | // plus a box registered to a customer fleet, read from |
| 67 | // fleets/<name>/status/boxes/ the way apisystemupdate.go's systemBoxes |
| 68 | // already does (R15, R21). |
| 69 | func TestAPIStatusLayoutListsBoxesRowsAndStale(t *testing.T) { |
| 70 | fx := newConsoleFixture(t) |
| 71 | token := cliToken(t, fx, "paul@keymail.dev", "code-apistatuslayoutrows") |
| 72 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | now := time.Now() |
| 76 | |
| 77 | seedLayoutBox(t, fx.rel, "fresh-box", now, &status.LayoutReport{ |
| 78 | Mode: "nested", Default: true, |
| 79 | Rows: map[string]status.RowLayout{ |
| 80 | "a.example.com": {Layout: "nested"}, |
| 81 | "b.example.com": {Layout: "foreign", Reason: "db and socket are neither default"}, |
| 82 | }, |
| 83 | }) |
| 84 | // Stale: past staleFactor(3)*SnapshotPeriod(5m) = 15 minutes dark. |
| 85 | seedLayoutBox(t, fx.rel, "stale-box", now.Add(-20*time.Minute), &status.LayoutReport{ |
| 86 | Mode: "flat", DefaultBlocked: "isolation not ready: probe fixture absent", |
| 87 | Rows: map[string]status.RowLayout{"c.example.com": {Layout: "flat"}}, |
| 88 | }) |
| 89 | |
| 90 | // A customer-fleet box: registered to the registry's fleet table, and |
| 91 | // its own snapshot written under the fleet's prefix — the shape |
| 92 | // systemBoxes reads, distinct from status/boxes/ (finding S14). |
| 93 | // A private handle, not openRegistry's shared one: since #208 the |
| 94 | // console's handle is process-owned and must not be closed by a caller. |
| 95 | reg, err := registry.Open(fx.regPath) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | t.Cleanup(func() { reg.Close() }) |
| 100 | if err := reg.UpsertFleet(registry.Fleet{Name: "pi", Account: fx.sqid, Bucket: "pi-bucket"}); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | fleetStore := release.NewPrefixStore(fx.rel, "fleets/pi/") |
| 104 | seedLayoutBox(t, fleetStore, "fleet-box", now, &status.LayoutReport{ |
| 105 | Mode: "flat", |
| 106 | Rows: map[string]status.RowLayout{"d.example.com": {Layout: "flat"}}, |
| 107 | }) |
| 108 | |
| 109 | status, body := getBearer(t, fx.ts.URL+"/api/cli/status/layout", token) |
| 110 | if status != http.StatusOK { |
| 111 | t.Fatalf("GET /api/cli/status/layout = %d %q", status, body) |
| 112 | } |
| 113 | var out struct { |
| 114 | Boxes []apiStatusLayoutBox `json:"boxes"` |
| 115 | } |
| 116 | if err := json.Unmarshal(body, &out); err != nil { |
| 117 | t.Fatalf("unreadable: %v: %s", err, body) |
| 118 | } |
| 119 | if len(out.Boxes) != 3 { |
| 120 | t.Fatalf("boxes = %+v, want fresh-box, stale-box and fleet-box", out.Boxes) |
| 121 | } |
| 122 | byLabel := map[string]apiStatusLayoutBox{} |
| 123 | for _, b := range out.Boxes { |
| 124 | byLabel[b.Box] = b |
| 125 | } |
| 126 | |
| 127 | fresh := byLabel["fresh-box"] |
| 128 | if !fresh.Default || fresh.DefaultBlocked != "" { |
| 129 | t.Errorf("fresh-box = %+v, want Default carried through from the snapshot", fresh) |
| 130 | } |
| 131 | if stale := byLabel["stale-box"]; stale.Default || stale.DefaultBlocked != "isolation not ready: probe fixture absent" { |
| 132 | t.Errorf("stale-box = %+v, want DefaultBlocked carried through", stale) |
| 133 | } |
| 134 | if !fresh.Mixed { |
| 135 | t.Errorf("fresh-box has a nested row beside a foreign one (uid carlos, no namespace) and is not marked mixed: %+v", fresh) |
| 136 | } |
| 137 | if fresh.Mode != "nested" || fresh.Stale { |
| 138 | t.Errorf("fresh-box = %+v", fresh) |
| 139 | } |
| 140 | if len(fresh.Rows) != 2 { |
| 141 | t.Fatalf("fresh-box rows = %+v", fresh.Rows) |
| 142 | } |
| 143 | byHost := map[string]apiStatusLayoutRow{} |
| 144 | for _, r := range fresh.Rows { |
| 145 | byHost[r.Host] = r |
| 146 | } |
| 147 | if byHost["a.example.com"].Layout != "nested" || byHost["a.example.com"].Reason != "" { |
| 148 | t.Errorf("a.example.com = %+v", byHost["a.example.com"]) |
| 149 | } |
| 150 | if byHost["b.example.com"].Layout != "foreign" || byHost["b.example.com"].Reason == "" { |
| 151 | t.Errorf("b.example.com = %+v", byHost["b.example.com"]) |
| 152 | } |
| 153 | |
| 154 | stale := byLabel["stale-box"] |
| 155 | if stale.Mode != "flat" || !stale.Stale { |
| 156 | t.Errorf("stale-box = %+v, want Stale true", stale) |
| 157 | } |
| 158 | |
| 159 | fleetBox := byLabel["fleet-box"] |
| 160 | if fleetBox.Mode != "flat" || len(fleetBox.Rows) != 1 || fleetBox.Rows[0].Host != "d.example.com" { |
| 161 | t.Errorf("fleet-box = %+v, want the fleet-prefixed snapshot to surface here", fleetBox) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // layoutMixed is the warning's rule: a nested row beside one that runs as |
| 166 | // uid carlos with no namespace (flat, foreign or external). A box that is |
| 167 | // all nested, or has no nested row, is not mixed. |
| 168 | func TestLayoutMixedNeedsANestedAndAFlatRow(t *testing.T) { |
| 169 | rows := func(layouts ...string) []apiStatusLayoutRow { |
| 170 | var out []apiStatusLayoutRow |
| 171 | for i, l := range layouts { |
| 172 | out = append(out, apiStatusLayoutRow{Host: fmt.Sprintf("h%d.example.com", i), Layout: l}) |
| 173 | } |
| 174 | return out |
| 175 | } |
| 176 | for _, tc := range []struct { |
| 177 | layouts []string |
| 178 | want bool |
| 179 | }{ |
| 180 | {[]string{"nested", "flat"}, true}, |
| 181 | {[]string{"flat", "fenced", "nested"}, true}, |
| 182 | {[]string{"nested", "nested"}, false}, |
| 183 | {[]string{"flat", "flat"}, false}, |
| 184 | {[]string{"nested", "foreign"}, true}, |
| 185 | {[]string{"nested", "external"}, true}, |
| 186 | {[]string{"nested", "fenced", "conflict"}, false}, |
| 187 | {[]string{"foreign", "external", "flat"}, false}, |
| 188 | {nil, false}, |
| 189 | } { |
| 190 | if got := layoutMixed("flat", rows(tc.layouts...)); got != tc.want { |
| 191 | t.Errorf("layoutMixed(flat, %v) = %v, want %v", tc.layouts, got, tc.want) |
| 192 | } |
| 193 | } |
| 194 | // Under a nested intent an unisolated row is mixed with or without a |
| 195 | // nested sibling; an all-nested or empty box is not. |
| 196 | for _, tc := range []struct { |
| 197 | layouts []string |
| 198 | want bool |
| 199 | }{ |
| 200 | {[]string{"foreign", "external"}, true}, |
| 201 | {[]string{"flat"}, true}, |
| 202 | {[]string{"nested", "nested"}, false}, |
| 203 | {[]string{"fenced"}, false}, |
| 204 | {nil, false}, |
| 205 | } { |
| 206 | if got := layoutMixed("nested", rows(tc.layouts...)); got != tc.want { |
| 207 | t.Errorf("layoutMixed(nested, %v) = %v, want %v", tc.layouts, got, tc.want) |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | |