| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/activator" |
| 11 | "github.com/carlosframework/platform/internal/metrics" |
| 12 | "github.com/carlosframework/platform/internal/registry" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // TestAPIStatusInstancesGate: operator only, with the same posture every |
| 17 | // operator door here has — anyone else sees the byte-identical 404 an unknown |
| 18 | // route gets. It matters more than usual on this door: the answer names other |
| 19 | // people's accounts and what they are running, so the refusal must give away |
| 20 | // neither the rows nor whether the caller holds the operator bit. It does not |
| 21 | // hide the PATH — a wrong-method request gets the mux's 405 before any of |
| 22 | // this — and the handler's comment says so; what is asserted here is what the |
| 23 | // 404 actually buys. |
| 24 | func TestAPIStatusInstancesGate(t *testing.T) { |
| 25 | fx := newConsoleFixture(t) |
| 26 | token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusinstances") |
| 27 | ghostStatus, ghostBody := getBearer(t, fx.ts.URL+"/this-api-route-does-not-exist", token) |
| 28 | |
| 29 | if status, _ := getBearer(t, fx.ts.URL+"/api/cli/status/instances", ""); status != http.StatusUnauthorized { |
| 30 | t.Fatalf("unauthenticated = %d; want the CLI door's 401", status) |
| 31 | } |
| 32 | if status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances", token); status != ghostStatus || string(body) != string(ghostBody) { |
| 33 | t.Fatalf("non-operator = %d %q; want the ghost 404 (%d %q)", status, body, ghostStatus, ghostBody) |
| 34 | } |
| 35 | |
| 36 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances", token) |
| 40 | if status != http.StatusOK { |
| 41 | t.Fatalf("operator = %d %q", status, body) |
| 42 | } |
| 43 | var out struct { |
| 44 | Instances []apiStatusInstance `json:"instances"` |
| 45 | } |
| 46 | if err := json.Unmarshal(body, &out); err != nil { |
| 47 | t.Fatalf("unreadable: %v: %s", err, body) |
| 48 | } |
| 49 | // Never null: a client that decodes null gets a nil slice and prints |
| 50 | // nothing, which reads the same as "no instances" for the wrong reason. |
| 51 | if out.Instances == nil { |
| 52 | t.Error("instances decoded as null; want an empty array") |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestAPIStatusInstancesKeepsSharedRegistryOpen(t *testing.T) { |
| 57 | fx := newConsoleFixture(t) |
| 58 | token := cliToken(t, fx, "paul@keymail.dev", "code-status-registry-lifetime") |
| 59 | fx.srv.cfg.AppsDomain = "oncarlos.test" |
| 60 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | tenancyFixture(t, fx) |
| 64 | statusInstanceRows(t, fx, token, "") |
| 65 | |
| 66 | reg, err := openRegistry(fx.regPath) |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | _, err = reg.ListPaths() |
| 71 | if err != nil { |
| 72 | t.Fatalf("registry read after status request: %v", err) |
| 73 | } |
| 74 | if rows := statusInstanceRows(t, fx, token, ""); len(rows) != 3 { |
| 75 | t.Fatalf("second status request = %d rows, want 3", len(rows)) |
| 76 | } |
| 77 | status, body := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "afterstatus"}, token) |
| 78 | if status != http.StatusOK { |
| 79 | t.Fatalf("app creation after status request = %d %v", status, body) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // tenancyFixture puts real readings behind the door: one box, three sampled |
| 84 | // units, two of them owned by routes belonging to DIFFERENT accounts and one |
| 85 | // owned by no route at all. |
| 86 | // |
| 87 | // It exists because without it the tests below assert nothing. No other test |
| 88 | // in this package writes anything under metrics/boxes/, so LoadMetrics finds |
| 89 | // no boxes, the handler's loop over box units never runs, every answer is an |
| 90 | // empty array, and every loop a test writes over that answer runs zero times. |
| 91 | // The --account filter test in particular stayed green with the server-side |
| 92 | // filter deleted. A fixture is the difference between a test that reads like |
| 93 | // a pin and one that is one. |
| 94 | // |
| 95 | // The unit names are built the way the handler matches them — through |
| 96 | // activator.UnitName, which is what routeForUnit compares against — so what |
| 97 | // is under test is the real matching rule rather than a string this file |
| 98 | // agreed with itself about. |
| 99 | func tenancyFixture(t *testing.T, fx *consoleFixture) { |
| 100 | t.Helper() |
| 101 | // fx.sqid is the built-in ops account; "cdef" is somebody else. Two |
| 102 | // tenants on one box is the whole point: a filter can only be shown to |
| 103 | // narrow if there is something for it to leave behind. |
| 104 | fx.addRoute(t, registry.Route{Host: "ours.example.com", Kind: "instance", Status: "ready", |
| 105 | App: "kass", Account: fx.sqid, Channel: "stable"}) |
| 106 | fx.addRoute(t, registry.Route{Host: "theirs.example.com", Kind: "instance", Status: "ready", |
| 107 | App: "widgets", Account: "cdef", Channel: "stable"}) |
| 108 | at := time.Now().UTC().Add(-time.Minute) |
| 109 | cpu, mem := 0.25, int64(1<<20) |
| 110 | if err := metrics.WriteRaw(context.Background(), fx.rel, metrics.RawChunk{ |
| 111 | V: 1, Box: "box-tenancy", Hour: metrics.HourOf(at), |
| 112 | Samples: []metrics.Sample{{At: at, Units: map[string]metrics.UnitSample{ |
| 113 | // A full reading, a partial one (CPU but no memory — the reason |
| 114 | // those fields are pointers), and a unit matching no route at |
| 115 | // all: the console's own, which is platform overhead. |
| 116 | activator.UnitName("ours.example.com"): {CPUFrac: &cpu, MemBytes: &mem}, |
| 117 | activator.UnitName("theirs.example.com"): {CPUFrac: &cpu}, |
| 118 | "console.service": {}, |
| 119 | }}}, |
| 120 | }); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // statusInstanceRows asks the door and decodes it, failing the test on any |
| 126 | // answer that is not a 200 carrying an array. |
| 127 | func statusInstanceRows(t *testing.T, fx *consoleFixture, token, query string) []apiStatusInstance { |
| 128 | t.Helper() |
| 129 | status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances"+query, token) |
| 130 | if status != http.StatusOK { |
| 131 | t.Fatalf("GET /api/cli/status/instances%s = %d %q", query, status, body) |
| 132 | } |
| 133 | var out struct { |
| 134 | Instances []apiStatusInstance `json:"instances"` |
| 135 | } |
| 136 | if err := json.Unmarshal(body, &out); err != nil { |
| 137 | t.Fatalf("unreadable: %v: %s", err, body) |
| 138 | } |
| 139 | return out.Instances |
| 140 | } |
| 141 | |
| 142 | // TestAPIStatusInstancesAttributesUnitsToAccounts pins the join itself: a |
| 143 | // sampled unit is carried to the account that owns the route it belongs to, |
| 144 | // and a unit belonging to no route stays in the answer marked as platform |
| 145 | // overhead rather than being dropped. Dropping it would be the quieter bug of |
| 146 | // the two — an operator asking what is on a box would get a list that omits |
| 147 | // the box's own load and never says it did. |
| 148 | func TestAPIStatusInstancesAttributesUnitsToAccounts(t *testing.T) { |
| 149 | fx := newConsoleFixture(t) |
| 150 | token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusjoin") |
| 151 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | tenancyFixture(t, fx) |
| 155 | |
| 156 | rows := statusInstanceRows(t, fx, token, "") |
| 157 | byUnit := map[string]apiStatusInstance{} |
| 158 | for _, r := range rows { |
| 159 | byUnit[r.Unit] = r |
| 160 | } |
| 161 | if len(rows) != 3 || len(byUnit) != 3 { |
| 162 | t.Fatalf("unfiltered = %d rows %+v, want one per sampled unit", len(rows), rows) |
| 163 | } |
| 164 | |
| 165 | ours := byUnit[activator.UnitName("ours.example.com")] |
| 166 | if ours.Account != fx.sqid || ours.App != "kass" || ours.Host != "ours.example.com" || ours.Platform { |
| 167 | t.Errorf("ours = %+v, want the ops account's kass route rather than platform", ours) |
| 168 | } |
| 169 | if ours.Box != "box-tenancy" || ours.CPU == nil || *ours.CPU != 0.25 || ours.Mem == nil || *ours.Mem != 1<<20 { |
| 170 | t.Errorf("ours = %+v, want box-tenancy carrying its own readings", ours) |
| 171 | } |
| 172 | |
| 173 | theirs := byUnit[activator.UnitName("theirs.example.com")] |
| 174 | if theirs.Account != "cdef" || theirs.App != "widgets" || theirs.Host != "theirs.example.com" || theirs.Platform { |
| 175 | t.Errorf("theirs = %+v, want cdef's widgets route", theirs) |
| 176 | } |
| 177 | // A sampled unit with no memory reading must arrive as nil rather than 0: |
| 178 | // a zero is a number an operator would act on. |
| 179 | if theirs.Mem != nil { |
| 180 | t.Errorf("theirs mem = %d, want nil — the sample carried no reading", *theirs.Mem) |
| 181 | } |
| 182 | |
| 183 | plat := byUnit["console.service"] |
| 184 | if !plat.Platform || plat.Account != release.OpsAccount || plat.App != "" || plat.Host != "" { |
| 185 | t.Errorf("console.service = %+v, want platform overhead charged to ops", plat) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | // TestAPIStatusInstancesAccountFilterNarrows: --account is the "is THIS member |
| 190 | // hogging the box?" form of the question, which is the one that prompted the |
| 191 | // door (discussion #11). The filter is applied on the SERVER, and it has to |
| 192 | // be: a filter that silently ignores itself is how an operator reads another |
| 193 | // account's rows as the one they asked about, and a client-side one would put |
| 194 | // every account's rows on the wire to get there. |
| 195 | // |
| 196 | // Asserted against a populated box, so "narrows" means fewer rows than the |
| 197 | // unfiltered answer rather than an empty list being trivially consistent with |
| 198 | // every claim anyone could make about it. |
| 199 | func TestAPIStatusInstancesAccountFilterNarrows(t *testing.T) { |
| 200 | fx := newConsoleFixture(t) |
| 201 | token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusfilter") |
| 202 | if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | tenancyFixture(t, fx) |
| 206 | |
| 207 | if all := statusInstanceRows(t, fx, token, ""); len(all) != 3 { |
| 208 | t.Fatalf("unfiltered = %d rows %+v, want three — the fixture is what makes the rest of this test mean anything", len(all), all) |
| 209 | } |
| 210 | |
| 211 | // A known account: exactly its own row, and nobody else's. |
| 212 | only := statusInstanceRows(t, fx, token, "?account=cdef") |
| 213 | if len(only) != 1 { |
| 214 | t.Fatalf("?account=cdef = %d rows %+v, want just the one", len(only), only) |
| 215 | } |
| 216 | if got, want := only[0].Unit, activator.UnitName("theirs.example.com"); got != want { |
| 217 | t.Errorf("?account=cdef returned %q, want %q", got, want) |
| 218 | } |
| 219 | |
| 220 | // An unknown account narrows to nothing rather than falling back to |
| 221 | // everything. |
| 222 | if rows := statusInstanceRows(t, fx, token, "?account=nosuchacct"); len(rows) != 0 { |
| 223 | t.Errorf("?account=nosuchacct = %+v, want no rows at all", rows) |
| 224 | } |
| 225 | } |
| 226 | |