package console import ( "context" "encoding/json" "net/http" "testing" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/metrics" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // TestAPIStatusInstancesGate: operator only, with the same posture every // operator door here has — anyone else sees the byte-identical 404 an unknown // route gets. It matters more than usual on this door: the answer names other // people's accounts and what they are running, so the refusal must give away // neither the rows nor whether the caller holds the operator bit. It does not // hide the PATH — a wrong-method request gets the mux's 405 before any of // this — and the handler's comment says so; what is asserted here is what the // 404 actually buys. func TestAPIStatusInstancesGate(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusinstances") ghostStatus, ghostBody := getBearer(t, fx.ts.URL+"/this-api-route-does-not-exist", token) if status, _ := getBearer(t, fx.ts.URL+"/api/cli/status/instances", ""); status != http.StatusUnauthorized { t.Fatalf("unauthenticated = %d; want the CLI door's 401", status) } if status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances", token); status != ghostStatus || string(body) != string(ghostBody) { t.Fatalf("non-operator = %d %q; want the ghost 404 (%d %q)", status, body, ghostStatus, ghostBody) } if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances", token) if status != http.StatusOK { t.Fatalf("operator = %d %q", status, body) } var out struct { Instances []apiStatusInstance `json:"instances"` } if err := json.Unmarshal(body, &out); err != nil { t.Fatalf("unreadable: %v: %s", err, body) } // Never null: a client that decodes null gets a nil slice and prints // nothing, which reads the same as "no instances" for the wrong reason. if out.Instances == nil { t.Error("instances decoded as null; want an empty array") } } func TestAPIStatusInstancesKeepsSharedRegistryOpen(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-status-registry-lifetime") fx.srv.cfg.AppsDomain = "oncarlos.test" if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } tenancyFixture(t, fx) statusInstanceRows(t, fx, token, "") reg, err := openRegistry(fx.regPath) if err != nil { t.Fatal(err) } _, err = reg.ListPaths() if err != nil { t.Fatalf("registry read after status request: %v", err) } if rows := statusInstanceRows(t, fx, token, ""); len(rows) != 3 { t.Fatalf("second status request = %d rows, want 3", len(rows)) } status, body := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "afterstatus"}, token) if status != http.StatusOK { t.Fatalf("app creation after status request = %d %v", status, body) } } // tenancyFixture puts real readings behind the door: one box, three sampled // units, two of them owned by routes belonging to DIFFERENT accounts and one // owned by no route at all. // // It exists because without it the tests below assert nothing. No other test // in this package writes anything under metrics/boxes/, so LoadMetrics finds // no boxes, the handler's loop over box units never runs, every answer is an // empty array, and every loop a test writes over that answer runs zero times. // The --account filter test in particular stayed green with the server-side // filter deleted. A fixture is the difference between a test that reads like // a pin and one that is one. // // The unit names are built the way the handler matches them — through // activator.UnitName, which is what routeForUnit compares against — so what // is under test is the real matching rule rather than a string this file // agreed with itself about. func tenancyFixture(t *testing.T, fx *consoleFixture) { t.Helper() // fx.sqid is the built-in ops account; "cdef" is somebody else. Two // tenants on one box is the whole point: a filter can only be shown to // narrow if there is something for it to leave behind. fx.addRoute(t, registry.Route{Host: "ours.example.com", Kind: "instance", Status: "ready", App: "kass", Account: fx.sqid, Channel: "stable"}) fx.addRoute(t, registry.Route{Host: "theirs.example.com", Kind: "instance", Status: "ready", App: "widgets", Account: "cdef", Channel: "stable"}) at := time.Now().UTC().Add(-time.Minute) cpu, mem := 0.25, int64(1<<20) if err := metrics.WriteRaw(context.Background(), fx.rel, metrics.RawChunk{ V: 1, Box: "box-tenancy", Hour: metrics.HourOf(at), Samples: []metrics.Sample{{At: at, Units: map[string]metrics.UnitSample{ // A full reading, a partial one (CPU but no memory — the reason // those fields are pointers), and a unit matching no route at // all: the console's own, which is platform overhead. activator.UnitName("ours.example.com"): {CPUFrac: &cpu, MemBytes: &mem}, activator.UnitName("theirs.example.com"): {CPUFrac: &cpu}, "console.service": {}, }}}, }); err != nil { t.Fatal(err) } } // statusInstanceRows asks the door and decodes it, failing the test on any // answer that is not a 200 carrying an array. func statusInstanceRows(t *testing.T, fx *consoleFixture, token, query string) []apiStatusInstance { t.Helper() status, body := getBearer(t, fx.ts.URL+"/api/cli/status/instances"+query, token) if status != http.StatusOK { t.Fatalf("GET /api/cli/status/instances%s = %d %q", query, status, body) } var out struct { Instances []apiStatusInstance `json:"instances"` } if err := json.Unmarshal(body, &out); err != nil { t.Fatalf("unreadable: %v: %s", err, body) } return out.Instances } // TestAPIStatusInstancesAttributesUnitsToAccounts pins the join itself: a // sampled unit is carried to the account that owns the route it belongs to, // and a unit belonging to no route stays in the answer marked as platform // overhead rather than being dropped. Dropping it would be the quieter bug of // the two — an operator asking what is on a box would get a list that omits // the box's own load and never says it did. func TestAPIStatusInstancesAttributesUnitsToAccounts(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusjoin") if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } tenancyFixture(t, fx) rows := statusInstanceRows(t, fx, token, "") byUnit := map[string]apiStatusInstance{} for _, r := range rows { byUnit[r.Unit] = r } if len(rows) != 3 || len(byUnit) != 3 { t.Fatalf("unfiltered = %d rows %+v, want one per sampled unit", len(rows), rows) } ours := byUnit[activator.UnitName("ours.example.com")] if ours.Account != fx.sqid || ours.App != "kass" || ours.Host != "ours.example.com" || ours.Platform { t.Errorf("ours = %+v, want the ops account's kass route rather than platform", ours) } if ours.Box != "box-tenancy" || ours.CPU == nil || *ours.CPU != 0.25 || ours.Mem == nil || *ours.Mem != 1<<20 { t.Errorf("ours = %+v, want box-tenancy carrying its own readings", ours) } theirs := byUnit[activator.UnitName("theirs.example.com")] if theirs.Account != "cdef" || theirs.App != "widgets" || theirs.Host != "theirs.example.com" || theirs.Platform { t.Errorf("theirs = %+v, want cdef's widgets route", theirs) } // A sampled unit with no memory reading must arrive as nil rather than 0: // a zero is a number an operator would act on. if theirs.Mem != nil { t.Errorf("theirs mem = %d, want nil — the sample carried no reading", *theirs.Mem) } plat := byUnit["console.service"] if !plat.Platform || plat.Account != release.OpsAccount || plat.App != "" || plat.Host != "" { t.Errorf("console.service = %+v, want platform overhead charged to ops", plat) } } // TestAPIStatusInstancesAccountFilterNarrows: --account is the "is THIS member // hogging the box?" form of the question, which is the one that prompted the // door (discussion #11). The filter is applied on the SERVER, and it has to // be: a filter that silently ignores itself is how an operator reads another // account's rows as the one they asked about, and a client-side one would put // every account's rows on the wire to get there. // // Asserted against a populated box, so "narrows" means fewer rows than the // unfiltered answer rather than an empty list being trivially consistent with // every claim anyone could make about it. func TestAPIStatusInstancesAccountFilterNarrows(t *testing.T) { fx := newConsoleFixture(t) token := cliToken(t, fx, "paul@keymail.dev", "code-apistatusfilter") if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } tenancyFixture(t, fx) if all := statusInstanceRows(t, fx, token, ""); len(all) != 3 { t.Fatalf("unfiltered = %d rows %+v, want three — the fixture is what makes the rest of this test mean anything", len(all), all) } // A known account: exactly its own row, and nobody else's. only := statusInstanceRows(t, fx, token, "?account=cdef") if len(only) != 1 { t.Fatalf("?account=cdef = %d rows %+v, want just the one", len(only), only) } if got, want := only[0].Unit, activator.UnitName("theirs.example.com"); got != want { t.Errorf("?account=cdef returned %q, want %q", got, want) } // An unknown account narrows to nothing rather than falling back to // everything. if rows := statusInstanceRows(t, fx, token, "?account=nosuchacct"); len(rows) != 0 { t.Errorf("?account=nosuchacct = %+v, want no rows at all", rows) } }