| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "mime/multipart" |
| 8 | "net/http" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // uploadBuild parks a local build through the upload door and returns the |
| 17 | // status and, on a 200, the handle. |
| 18 | func uploadBuild(t *testing.T, fx *consoleFixture, token string) (int, string) { |
| 19 | t.Helper() |
| 20 | var buf bytes.Buffer |
| 21 | mw := multipart.NewWriter(&buf) |
| 22 | meta, _ := mw.CreateFormField("meta") |
| 23 | meta.Write([]byte(`{"version":"66587fa","platform":"linux-arm64"}`)) |
| 24 | file, _ := mw.CreateFormFile("file", "carlos-linux-arm64") |
| 25 | file.Write([]byte("ELF local")) |
| 26 | mw.Close() |
| 27 | req, _ := http.NewRequest(http.MethodPut, fx.ts.URL+"/api/cli/system/upload", &buf) |
| 28 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 29 | req.Header.Set("Authorization", "Bearer "+token) |
| 30 | res, err := http.DefaultClient.Do(req) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | defer res.Body.Close() |
| 35 | if res.StatusCode != http.StatusOK { |
| 36 | return res.StatusCode, "" |
| 37 | } |
| 38 | var up map[string]any |
| 39 | if err := json.NewDecoder(res.Body).Decode(&up); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | return res.StatusCode, str(t, up, "upload") |
| 43 | } |
| 44 | |
| 45 | // TestSystemOperatorRollsSignedReleases: an address on SystemOperators who |
| 46 | // owns nothing in the ops account can do the whole of a roll — status, an |
| 47 | // update from the signed feed, watching its job, and a rollback — and every |
| 48 | // move is recorded against that address, not against an owner. |
| 49 | func TestSystemOperatorRollsSignedReleases(t *testing.T) { |
| 50 | fx := newConsoleFixture(t) |
| 51 | fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"} |
| 52 | systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")}) |
| 53 | seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0") |
| 54 | sg := release.RootSigner(fx.priv) |
| 55 | release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", |
| 56 | 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)}}}, |
| 57 | release.SystemPromoteOptions{}) |
| 58 | time.Sleep(1100 * time.Millisecond) |
| 59 | bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-bill") |
| 60 | |
| 61 | if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK { |
| 62 | t.Fatalf("status as an operator = %d %v, want 200", st, out) |
| 63 | } |
| 64 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, bill) |
| 65 | if st != http.StatusOK { |
| 66 | t.Fatalf("update as an operator = %d %v, want 200", st, out) |
| 67 | } |
| 68 | if job := waitJob(t, fx, bill, str(t, out, "job")); job["error"] != "" { |
| 69 | t.Fatalf("operator's job: %v", job) |
| 70 | } |
| 71 | p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary") |
| 72 | if err != nil || p.Current.Version != "v0.18.0" { |
| 73 | t.Fatalf("pointer after the operator's update = %+v %v", p, err) |
| 74 | } |
| 75 | if len(p.Audit) == 0 || p.Audit[0].Actor != "bill@keymail.dev" { |
| 76 | t.Fatalf("pointer audit = %+v, want the operator as the actor", p.Audit) |
| 77 | } |
| 78 | time.Sleep(1100 * time.Millisecond) |
| 79 | st, out = postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, bill) |
| 80 | if st != http.StatusOK || str(t, out, "version") != "v0.17.0" { |
| 81 | t.Fatalf("rollback as an operator = %d %v, want 200 onto v0.17.0", st, out) |
| 82 | } |
| 83 | events, err := fx.st.Events(OpsAccountID, "platform", 10) |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | actions := map[string]string{} |
| 88 | for _, e := range events { |
| 89 | actions[e.Action] = e.Address |
| 90 | } |
| 91 | for _, a := range []string{"system-update", "system-rollback"} { |
| 92 | if actions[a] != "bill@keymail.dev" { |
| 93 | t.Errorf("%s audit row by %q, want the operator", a, actions[a]) |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // TestSystemOperatorCannotShipALocalBuild: the console signs whatever it is |
| 99 | // uploaded with the deployment key and every box runs it, so a local build |
| 100 | // stays the ops owner's. An operator is refused at the upload door, and |
| 101 | // cannot spend an owner's parked upload by naming its handle either: the |
| 102 | // refusal comes before the handle is consumed, so the owner's promote still |
| 103 | // finds it. |
| 104 | func TestSystemOperatorCannotShipALocalBuild(t *testing.T) { |
| 105 | fx := newConsoleFixture(t) |
| 106 | fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"} |
| 107 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sysop-owner") |
| 108 | bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-nofile") |
| 109 | |
| 110 | if st, _ := uploadBuild(t, fx, bill); st != http.StatusForbidden { |
| 111 | t.Fatalf("upload as an operator = %d, want 403", st) |
| 112 | } |
| 113 | st, handle := uploadBuild(t, fx, owner) |
| 114 | if st != http.StatusOK { |
| 115 | t.Fatalf("upload as the owner = %d", st) |
| 116 | } |
| 117 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "upload": handle}, bill) |
| 118 | if st != http.StatusForbidden { |
| 119 | t.Fatalf("update naming an upload as an operator = %d %v, want 403", st, out) |
| 120 | } |
| 121 | if str(t, out, "error") == "" { |
| 122 | t.Fatalf("operator's refusal = %v, want a sentence", out) |
| 123 | } |
| 124 | st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "upload": handle}, owner) |
| 125 | if st != http.StatusOK { |
| 126 | t.Fatalf("owner's update = %d %v", st, out) |
| 127 | } |
| 128 | if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" { |
| 129 | t.Fatalf("owner's upload was consumed by the operator's refused request: %v", job) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | // TestSystemOperatorListGatesWhoGetsIn: the list is the whole grant. Off it, |
| 134 | // a person gets the same bare 404 a nonexistent path gives; an empty list is |
| 135 | // exactly the owner-only console that existed before it. Entries are |
| 136 | // trimmed and lowercased the way the other operator lists are, because the |
| 137 | // list is typed by a person into an env file. |
| 138 | func TestSystemOperatorListGatesWhoGetsIn(t *testing.T) { |
| 139 | fx := newConsoleFixture(t) |
| 140 | bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-list") |
| 141 | _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", bill) |
| 142 | for _, list := range [][]string{nil, {"alice@keymail.dev"}, {"bill@keymail.dev.evil"}} { |
| 143 | fx.srv.cfg.SystemOperators = list |
| 144 | if st, body := getRaw(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusNotFound || body != want { |
| 145 | t.Errorf("status with SystemOperators=%q = %d %q, want 404 %q", list, st, body, want) |
| 146 | } |
| 147 | if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, bill); st != http.StatusNotFound || body != want { |
| 148 | t.Errorf("rollback with SystemOperators=%q = %d %q, want 404 %q", list, st, body, want) |
| 149 | } |
| 150 | // The owner-only 403 is for callers the gate has ADMITTED. Off the |
| 151 | // list, the two upload paths must answer the same 404 as every other |
| 152 | // door, or the 403's sentence confirms the surface exists. |
| 153 | if st, _ := uploadBuild(t, fx, bill); st != http.StatusNotFound { |
| 154 | t.Errorf("upload with SystemOperators=%q = %d, want 404", list, st) |
| 155 | } |
| 156 | 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 { |
| 157 | t.Errorf("update naming an upload with SystemOperators=%q = %d %q, want 404 %q", list, st, body, want) |
| 158 | } |
| 159 | } |
| 160 | fx.srv.cfg.SystemOperators = []string{" Bill@Keymail.dev "} |
| 161 | if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK { |
| 162 | t.Fatalf("status with a padded, mixed-case entry = %d %v, want 200", st, out) |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // TestSystemRollerStoreErrorAdmitsNobody: a failed ownership lookup is |
| 167 | // logged and the list is still consulted, so a listed operator keeps working |
| 168 | // through a store fault. The fault itself must never admit anyone: an owner |
| 169 | // the console cannot see is refused like a stranger, and the listed operator |
| 170 | // gets in as an operator, never as an owner. |
| 171 | func TestSystemRollerStoreErrorAdmitsNobody(t *testing.T) { |
| 172 | fx := newConsoleFixture(t) |
| 173 | fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"} |
| 174 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sysop-err-owner") |
| 175 | bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-err-bill") |
| 176 | _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", owner) |
| 177 | if _, err := fx.st.db.Exec(`ALTER TABLE memberships RENAME TO memberships_gone`); err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | if _, err := fx.st.Role(OpsAccountID, "paul@keymail.dev"); err == nil { |
| 181 | t.Fatal("Role still answers; the fault this test needs did not happen") |
| 182 | } |
| 183 | if st, body := getRaw(t, fx.ts.URL+"/api/cli/system/status", owner); st != http.StatusNotFound || body != want { |
| 184 | t.Errorf("status as an unlisted owner during a store fault = %d %q, want 404 %q", st, body, want) |
| 185 | } |
| 186 | if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK { |
| 187 | t.Errorf("status as a listed operator during a store fault = %d %v, want 200", st, out) |
| 188 | } |
| 189 | if st, _ := uploadBuild(t, fx, bill); st != http.StatusForbidden { |
| 190 | t.Errorf("upload as a listed operator during a store fault = %d, want 403 (an operator, not an owner)", st) |
| 191 | } |
| 192 | } |
| 193 | |