package console import ( "bytes" "context" "encoding/json" "mime/multipart" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) // uploadBuild parks a local build through the upload door and returns the // status and, on a 200, the handle. func uploadBuild(t *testing.T, fx *consoleFixture, token string) (int, string) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) meta, _ := mw.CreateFormField("meta") meta.Write([]byte(`{"version":"66587fa","platform":"linux-arm64"}`)) file, _ := mw.CreateFormFile("file", "carlos-linux-arm64") file.Write([]byte("ELF local")) mw.Close() req, _ := http.NewRequest(http.MethodPut, fx.ts.URL+"/api/cli/system/upload", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+token) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { return res.StatusCode, "" } var up map[string]any if err := json.NewDecoder(res.Body).Decode(&up); err != nil { t.Fatal(err) } return res.StatusCode, str(t, up, "upload") } // TestSystemOperatorRollsSignedReleases: an address on SystemOperators who // owns nothing in the ops account can do the whole of a roll — status, an // update from the signed feed, watching its job, and a rollback — and every // move is recorded against that address, not against an owner. func TestSystemOperatorRollsSignedReleases(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"} systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")}) seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0") sg := release.RootSigner(fx.priv) release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", release.SystemRelease{Version: "v0.17.0", Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey("v0.17.0", "linux-arm64"), SHA256: strings.Repeat("a", 64)}}}, release.SystemPromoteOptions{}) time.Sleep(1100 * time.Millisecond) bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-bill") if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK { t.Fatalf("status as an operator = %d %v, want 200", st, out) } st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, bill) if st != http.StatusOK { t.Fatalf("update as an operator = %d %v, want 200", st, out) } if job := waitJob(t, fx, bill, str(t, out, "job")); job["error"] != "" { t.Fatalf("operator's job: %v", job) } p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary") if err != nil || p.Current.Version != "v0.18.0" { t.Fatalf("pointer after the operator's update = %+v %v", p, err) } if len(p.Audit) == 0 || p.Audit[0].Actor != "bill@keymail.dev" { t.Fatalf("pointer audit = %+v, want the operator as the actor", p.Audit) } time.Sleep(1100 * time.Millisecond) st, out = postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, bill) if st != http.StatusOK || str(t, out, "version") != "v0.17.0" { t.Fatalf("rollback as an operator = %d %v, want 200 onto v0.17.0", st, out) } events, err := fx.st.Events(OpsAccountID, "platform", 10) if err != nil { t.Fatal(err) } actions := map[string]string{} for _, e := range events { actions[e.Action] = e.Address } for _, a := range []string{"system-update", "system-rollback"} { if actions[a] != "bill@keymail.dev" { t.Errorf("%s audit row by %q, want the operator", a, actions[a]) } } } // TestSystemOperatorCannotShipALocalBuild: the console signs whatever it is // uploaded with the deployment key and every box runs it, so a local build // stays the ops owner's. An operator is refused at the upload door, and // cannot spend an owner's parked upload by naming its handle either: the // refusal comes before the handle is consumed, so the owner's promote still // finds it. func TestSystemOperatorCannotShipALocalBuild(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"} owner := cliToken(t, fx, "paul@keymail.dev", "code-sysop-owner") bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-nofile") if st, _ := uploadBuild(t, fx, bill); st != http.StatusForbidden { t.Fatalf("upload as an operator = %d, want 403", st) } st, handle := uploadBuild(t, fx, owner) if st != http.StatusOK { t.Fatalf("upload as the owner = %d", st) } st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "upload": handle}, bill) if st != http.StatusForbidden { t.Fatalf("update naming an upload as an operator = %d %v, want 403", st, out) } if str(t, out, "error") == "" { t.Fatalf("operator's refusal = %v, want a sentence", out) } st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "upload": handle}, owner) if st != http.StatusOK { t.Fatalf("owner's update = %d %v", st, out) } if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" { t.Fatalf("owner's upload was consumed by the operator's refused request: %v", job) } } // TestSystemOperatorListGatesWhoGetsIn: the list is the whole grant. Off it, // a person gets the same bare 404 a nonexistent path gives; an empty list is // exactly the owner-only console that existed before it. Entries are // trimmed and lowercased the way the other operator lists are, because the // list is typed by a person into an env file. func TestSystemOperatorListGatesWhoGetsIn(t *testing.T) { fx := newConsoleFixture(t) bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-list") _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", bill) for _, list := range [][]string{nil, {"alice@keymail.dev"}, {"bill@keymail.dev.evil"}} { fx.srv.cfg.SystemOperators = list if st, body := getRaw(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusNotFound || body != want { t.Errorf("status with SystemOperators=%q = %d %q, want 404 %q", list, st, body, want) } if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, bill); st != http.StatusNotFound || body != want { t.Errorf("rollback with SystemOperators=%q = %d %q, want 404 %q", list, st, body, want) } // The owner-only 403 is for callers the gate has ADMITTED. Off the // list, the two upload paths must answer the same 404 as every other // door, or the 403's sentence confirms the surface exists. if st, _ := uploadBuild(t, fx, bill); st != http.StatusNotFound { t.Errorf("upload with SystemOperators=%q = %d, want 404", list, st) } if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/update", map[string]string{"channel": "canary", "upload": "deadbeef"}, bill); st != http.StatusNotFound || body != want { t.Errorf("update naming an upload with SystemOperators=%q = %d %q, want 404 %q", list, st, body, want) } } fx.srv.cfg.SystemOperators = []string{" Bill@Keymail.dev "} if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK { t.Fatalf("status with a padded, mixed-case entry = %d %v, want 200", st, out) } } // TestSystemRollerStoreErrorAdmitsNobody: a failed ownership lookup is // logged and the list is still consulted, so a listed operator keeps working // through a store fault. The fault itself must never admit anyone: an owner // the console cannot see is refused like a stranger, and the listed operator // gets in as an operator, never as an owner. func TestSystemRollerStoreErrorAdmitsNobody(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"} owner := cliToken(t, fx, "paul@keymail.dev", "code-sysop-err-owner") bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-err-bill") _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", owner) if _, err := fx.st.db.Exec(`ALTER TABLE memberships RENAME TO memberships_gone`); err != nil { t.Fatal(err) } if _, err := fx.st.Role(OpsAccountID, "paul@keymail.dev"); err == nil { t.Fatal("Role still answers; the fault this test needs did not happen") } if st, body := getRaw(t, fx.ts.URL+"/api/cli/system/status", owner); st != http.StatusNotFound || body != want { t.Errorf("status as an unlisted owner during a store fault = %d %q, want 404 %q", st, body, want) } if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK { t.Errorf("status as a listed operator during a store fault = %d %v, want 200", st, out) } if st, _ := uploadBuild(t, fx, bill); st != http.StatusForbidden { t.Errorf("upload as a listed operator during a store fault = %d, want 403 (an operator, not an owner)", st) } }