carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/hex"
10 "encoding/json"
11 "fmt"
12 "mime/multipart"
13 "net/http"
14 "net/http/httptest"
15 "os"
16 "path/filepath"
17 "reflect"
18 "strings"
19 "sync"
20 "testing"
21 "time"
22
23 "github.com/carlosframework/platform/internal/publicrelease"
24 "github.com/carlosframework/platform/internal/release"
25 "github.com/carlosframework/platform/internal/status"
26)
27
28// systemFeed serves a signed fake release from an httptest server and
29// points the fixture's console at it.
30func systemFeed(t *testing.T, fx *consoleFixture, assets map[string][]byte) {
31 t.Helper()
32 systemFeedHeld(t, fx, assets, nil)
33}
34
35// systemFeedHeld is systemFeed with a gate on /latest.txt: while hold is open
36// the feed never answers, so a promote that has to resolve "latest" stays in
37// flight for as long as the test needs it to.
38func systemFeedHeld(t *testing.T, fx *consoleFixture, assets map[string][]byte, hold <-chan struct{}) {
39 t.Helper()
40 pub, priv, _ := ed25519.GenerateKey(nil)
41 var sums strings.Builder
42 for name, body := range assets {
43 s := sha256.Sum256(body)
44 fmt.Fprintf(&sums, "%s %s\n", hex.EncodeToString(s[:]), name)
45 }
46 sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(sums.String())))
47 mux := http.NewServeMux()
48 mux.HandleFunc("/latest.txt", func(w http.ResponseWriter, r *http.Request) {
49 if hold != nil {
50 <-hold
51 }
52 fmt.Fprintln(w, "v0.18.0")
53 })
54 mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sums.String()) })
55 mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt.sig", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, sig) })
56 for name, body := range assets {
57 body := body
58 mux.HandleFunc("/dl/v0.18.0/"+name, func(w http.ResponseWriter, r *http.Request) { w.Write(body) })
59 }
60 ts := httptest.NewServer(mux)
61 t.Cleanup(ts.Close)
62 fx.srv.cfg.SystemFeed = publicrelease.Feed{LatestURL: ts.URL + "/latest.txt", DownloadBase: ts.URL + "/dl", PubKey: base64.StdEncoding.EncodeToString(pub)}
63}
64
65func waitJob(t *testing.T, fx *consoleFixture, token, id string) map[string]any {
66 t.Helper()
67 for i := 0; i < 100; i++ {
68 st, out := getJSON(t, fx.ts.URL+"/api/cli/system/jobs/"+id, token)
69 if st != http.StatusOK {
70 t.Fatalf("job = %d %v", st, out)
71 }
72 if out["done"] == true {
73 return out
74 }
75 time.Sleep(20 * time.Millisecond)
76 }
77 t.Fatal("job never finished")
78 return nil
79}
80
81func seedBox(t *testing.T, fx *consoleFixture, label, platform, channel, rel string) {
82 t.Helper()
83 b := status.CollectBox(nil, status.Probes{Release: rel, Platform: platform, SystemChannel: channel}, time.Now())
84 b.Label = label
85 if err := status.WriteBox(context.Background(), fx.rel, b); err != nil {
86 t.Fatal(err)
87 }
88}
89
90func TestSystemDoorsAreForOpsOwnersOnly(t *testing.T) {
91 fx := newConsoleFixture(t)
92 if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil {
93 t.Fatal(err)
94 }
95 member := cliToken(t, fx, "member@keymail.dev", "code-sys-member")
96 // The refusal is the BARE 404 the operator doors give — raw, not
97 // jsonError'd — so it is byte-identical to what a path that does not
98 // exist answers. That identity is the point: a member must not be able
99 // to learn that this deployment has a system surface at all, and the
100 // nonexistent path below is the baseline every refusal is measured
101 // against, which is why the assertions read the body and not just the
102 // status.
103 _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member)
104 for _, u := range []string{"/api/cli/system/status", "/api/cli/system/jobs/deadbeef"} {
105 st, body := getRaw(t, fx.ts.URL+u, member)
106 if st != http.StatusNotFound || body != want {
107 t.Errorf("GET %s as a member = %d %q, want 404 %q", u, st, body, want)
108 }
109 }
110 for _, u := range []string{"/api/cli/system/update", "/api/cli/system/rollback"} {
111 st, body := postRaw(t, fx.ts.URL+u, map[string]string{"channel": "canary"}, member)
112 if st != http.StatusNotFound || body != want {
113 t.Errorf("POST %s as a member = %d %q, want 404 %q", u, st, body, want)
114 }
115 }
116 if st, _ := getRaw(t, fx.ts.URL+"/api/cli/system/status", "not-a-token"); st == http.StatusOK {
117 t.Error("unauthenticated status answered 200")
118 }
119}
120
121// TestSystemDoorsWithoutABucketRefuse: Config.Releases == nil is a legal
122// deployment shape, and every door here reads or writes the bucket. An owner
123// on such a console must get a sentence, not the nil-interface panic
124// status.LoadBoxes and release.GetSystemPointer would otherwise give.
125func TestSystemDoorsWithoutABucketRefuse(t *testing.T) {
126 fx := newConsoleFixture(t)
127 owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-nobucket")
128 fx.srv.cfg.Releases = nil
129 if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", owner); st != http.StatusServiceUnavailable {
130 t.Errorf("status without a bucket = %d %v, want 503", st, out)
131 }
132 if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner); st != http.StatusServiceUnavailable {
133 t.Errorf("update without a bucket = %d %v, want 503", st, out)
134 }
135 if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner); st != http.StatusServiceUnavailable {
136 t.Errorf("rollback without a bucket = %d %v, want 503", st, out)
137 }
138}
139
140func TestSystemUpdateMirrorsSignsAndBumps(t *testing.T) {
141 fx := newConsoleFixture(t)
142 arm := []byte("ELF arm64")
143 systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": arm, "carlos-linux-amd64": []byte("ELF amd64")})
144 seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0")
145 owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-owner")
146 before, _, _ := release.ReadSerial(context.Background(), fx.rel)
147 st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "spread": "2m"}, owner)
148 if st != http.StatusOK {
149 t.Fatalf("update = %d %v", st, out)
150 }
151 job := waitJob(t, fx, owner, str(t, out, "job"))
152 if job["error"] != "" {
153 t.Fatalf("job failed: %v", job)
154 }
155 p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary")
156 if err != nil {
157 t.Fatal(err)
158 }
159 if p.Current.Version != "v0.18.0" || p.Spread != "2m0s" || p.Current.Source != "release:v0.18.0" {
160 t.Fatalf("pointer = %+v", p)
161 }
162 a, ok := p.Current.Artifacts["linux-arm64"]
163 if !ok {
164 t.Fatalf("no arm64 artifact: %+v", p.Current.Artifacts)
165 }
166 if _, ok := p.Current.Artifacts["linux-amd64"]; ok {
167 t.Fatal("mirrored a platform no box reports")
168 }
169 body, etag, err := fx.rel.Get(context.Background(), a.Key)
170 if err != nil || !bytes.Equal(body, arm) {
171 t.Fatalf("artifact not mirrored: %v", err)
172 }
173 s := sha256.Sum256(arm)
174 if a.SHA256 != hex.EncodeToString(s[:]) {
175 t.Fatalf("sha = %s", a.SHA256)
176 }
177 after, _, _ := release.ReadSerial(context.Background(), fx.rel)
178 if after.Serial == before.Serial {
179 t.Fatal("serial not bumped")
180 }
181 // A second promote of the same version to stable copies nothing and
182 // refuses different bytes under the same version.
183 st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "stable", "version": "v0.18.0"}, owner)
184 if st != http.StatusOK {
185 t.Fatalf("second update = %d %v", st, out)
186 }
187 if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" {
188 t.Fatalf("second job: %v", job)
189 }
190 // Overwrite the object out from under the pointer — a CAS-guarded Put
191 // with the etag we just read, because an empty ifMatch is create-only
192 // and would refuse an existing key. A re-promote of that version must
193 // then refuse: versions are immutable.
194 if _, err := fx.rel.Put(context.Background(), a.Key, []byte("different"), etag); err != nil {
195 t.Fatalf("overwrite the mirrored artifact: %v", err)
196 }
197 st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "version": "v0.18.0"}, owner)
198 if st != http.StatusOK {
199 t.Fatalf("re-promote = %d %v", st, out)
200 }
201 job = waitJob(t, fx, owner, str(t, out, "job"))
202 if e, _ := job["error"].(string); e == "" || !strings.Contains(e, "immutable") {
203 t.Fatalf("re-promote over different bytes: %v", job)
204 }
205}
206
207// TestSystemUpdateIsOnePromotePerChannel: a promote holds a whole platform
208// binary in the heap, and the door is reachable by a script. One at a time
209// per channel, and the second caller is told which job to watch rather than
210// being handed a parallel one.
211func TestSystemUpdateIsOnePromotePerChannel(t *testing.T) {
212 fx := newConsoleFixture(t)
213 hold := make(chan struct{})
214 var once sync.Once
215 unhold := func() { once.Do(func() { close(hold) }) }
216 defer unhold() // the feed server's Close waits on in-flight requests
217 systemFeedHeld(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")}, hold)
218 seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0")
219 owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-one")
220
221 st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner)
222 if st != http.StatusOK {
223 t.Fatalf("first update = %d %v", st, out)
224 }
225 first := str(t, out, "job")
226
227 // The slot is claimed by the handler, before the goroutine runs, so this
228 // needs no sleep to be deterministic.
229 st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner)
230 if st != http.StatusConflict {
231 t.Fatalf("second update on a busy channel = %d %v, want 409", st, out)
232 }
233 if str(t, out, "job") != first {
234 t.Errorf("409 named job %q, want the running one %q", str(t, out, "job"), first)
235 }
236 // A DIFFERENT channel is not blocked by canary's promote.
237 st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "stable"}, owner)
238 if st != http.StatusOK {
239 t.Fatalf("update on the other channel = %d %v, want 200", st, out)
240 }
241 stable := str(t, out, "job")
242
243 unhold()
244 // Both jobs mirror the SAME artifact key concurrently — one wins the
245 // create-only Put and the other must read it back and agree, not report
246 // a conflict the operator did not cause.
247 for _, id := range []string{first, stable} {
248 if job := waitJob(t, fx, owner, id); job["error"] != "" {
249 t.Fatalf("job %s: %v", id, job)
250 }
251 }
252 // The slot is handed back before the job is marked done, so an operator
253 // who watched it finish can promote again immediately.
254 st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner)
255 if st != http.StatusOK {
256 t.Fatalf("update after the first finished = %d %v, want 200", st, out)
257 }
258 if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" {
259 t.Fatalf("re-promote after the slot was handed back: %v", job)
260 }
261}
262
263// TestSystemUpdateRefusesWhenFleetsAreUnreadable: which platforms to mirror
264// is derived from what the fleet reports, so a fleet this console cannot
265// enumerate is not a fleet to quietly leave out — those boxes would never
266// find a binary to adopt. The promote refuses and says why.
267func TestSystemUpdateRefusesWhenFleetsAreUnreadable(t *testing.T) {
268 fx := newConsoleFixture(t)
269 systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")})
270 seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0")
271 owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-fleeterr")
272 // A registry that EXISTS and cannot be opened — a directory where the
273 // database should be. Not a missing path: a path that is not there yet is
274 // an ordinary console with no fleets, which the sibling test covers.
275 broken := filepath.Join(t.TempDir(), "registry.db")
276 if err := os.Mkdir(broken, 0o755); err != nil {
277 t.Fatal(err)
278 }
279 fx.srv.cfg.Registry = broken
280
281 st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner)
282 if st != http.StatusOK {
283 t.Fatalf("update = %d %v", st, out)
284 }
285 job := waitJob(t, fx, owner, str(t, out, "job"))
286 e, _ := job["error"].(string)
287 if !strings.Contains(e, "cannot list fleets") {
288 t.Fatalf("job error = %q, want a refusal naming the fleet listing", e)
289 }
290 if _, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary"); err == nil {
291 t.Error("the pointer moved even though the platform set was unknown")
292 }
293
294 // The STATUS read degrades instead: the boxes that did answer, plus the
295 // flag that says the list is short.
296 st, out = getJSON(t, fx.ts.URL+"/api/cli/system/status", owner)
297 if st != http.StatusOK {
298 t.Fatalf("status = %d %v", st, out)
299 }
300 if out["partial"] != true {
301 t.Errorf("status partial = %v, want true when a fleet could not be listed", out["partial"])
302 }
303 if boxes, _ := out["boxes"].([]any); len(boxes) != 1 {
304 t.Errorf("status boxes = %v, want the platform's own box still shown", out["boxes"])
305 }
306}
307
308// TestSystemUpdateWithNoRegistryHasNoFleets: a console with no route registry
309// — cfg.Registry unset, or the file not created yet — is an ordinary console
310// with no fleets, not one whose fleets are unreadable. It must be able to
311// promote, and its status must not claim to be short of anything.
312func TestSystemUpdateWithNoRegistryHasNoFleets(t *testing.T) {
313 for _, c := range []struct{ name, registry string }{
314 {"unset", ""},
315 {"not created yet", filepath.Join(t.TempDir(), "never-made.db")},
316 } {
317 t.Run(c.name, func(t *testing.T) {
318 fx := newConsoleFixture(t)
319 systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")})
320 owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-noreg-"+strings.ReplaceAll(c.name, " ", "-"))
321 fx.srv.cfg.Registry = c.registry
322
323 st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner)
324 if st != http.StatusOK {
325 t.Fatalf("update = %d %v", st, out)
326 }
327 if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" {
328 t.Fatalf("job: %v", job)
329 }
330 p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary")
331 if err != nil {
332 t.Fatal(err)
333 }
334 if _, ok := p.Current.Artifacts["linux-arm64"]; !ok {
335 t.Fatalf("artifacts = %+v, want linux-arm64 mirrored", p.Current.Artifacts)
336 }
337 st, out = getJSON(t, fx.ts.URL+"/api/cli/system/status", owner)
338 if st != http.StatusOK {
339 t.Fatalf("status = %d %v", st, out)
340 }
341 if out["partial"] != false {
342 t.Errorf("status partial = %v, want false — no registry is no fleets, not unreadable ones", out["partial"])
343 }
344 })
345 }
346}
347
348// TestSystemStatusNamesAnUnverifiablePointer: null means "never promoted"
349// and nothing else. A pointer that exists but does not verify is the one
350// thing an operator most needs to see, so it must not render as the one
351// thing they would scroll past.
352func TestSystemStatusNamesAnUnverifiablePointer(t *testing.T) {
353 fx := newConsoleFixture(t)
354 owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-tampered")
355 key := release.SystemPointerKey("router", "canary")
356 if _, err := fx.rel.Put(context.Background(), key, []byte(`{"payload":"bm90LWEtcG9pbnRlcg==","sig":"AAAA"}`), ""); err != nil {
357 t.Fatal(err)
358 }
359 st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", owner)
360 if st != http.StatusOK {
361 t.Fatalf("status = %d %v", st, out)
362 }
363 p, _ := out["pointers"].(map[string]any)
364 canary, ok := p["canary"].(map[string]any)
365 if !ok {
366 t.Fatalf("canary pointer = %v, want an object carrying the error (null would read as never promoted)", p["canary"])
367 }
368 if e, _ := canary["error"].(string); e == "" {
369 t.Errorf("canary pointer = %v, want an error naming the refusal", canary)
370 }
371 // stable really has never been promoted, and that IS null.
372 if p["stable"] != nil {
373 t.Errorf("stable pointer = %v, want null", p["stable"])
374 }
375}
376
377func TestSystemUpdateFromAnUpload(t *testing.T) {
378 fx := newConsoleFixture(t)
379 owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-upload")
380 var buf bytes.Buffer
381 mw := multipart.NewWriter(&buf)
382 meta, _ := mw.CreateFormField("meta")
383 meta.Write([]byte(`{"version":"66587fa","platform":"linux-arm64"}`))
384 file, _ := mw.CreateFormFile("file", "carlos-linux-arm64")
385 file.Write([]byte("ELF local"))
386 mw.Close()
387 req, _ := http.NewRequest(http.MethodPut, fx.ts.URL+"/api/cli/system/upload", &buf)
388 req.Header.Set("Content-Type", mw.FormDataContentType())
389 req.Header.Set("Authorization", "Bearer "+owner)
390 res, err := http.DefaultClient.Do(req)
391 if err != nil {
392 t.Fatal(err)
393 }
394 var up map[string]any
395 if res.StatusCode != http.StatusOK {
396 t.Fatalf("upload = %d", res.StatusCode)
397 }
398 if err := json.NewDecoder(res.Body).Decode(&up); err != nil {
399 t.Fatal(err)
400 }
401 st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "upload": up["upload"]}, owner)
402 if st != http.StatusOK {
403 t.Fatalf("update = %d %v", st, out)
404 }
405 if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" {
406 t.Fatalf("job: %v", job)
407 }
408 p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary")
409 if err != nil || p.Current.Version != "66587fa" || p.Current.Source != "file" {
410 t.Fatalf("pointer = %+v %v", p, err)
411 }
412 // Upload without a platform is refused at the door.
413 var buf2 bytes.Buffer
414 mw2 := multipart.NewWriter(&buf2)
415 m2, _ := mw2.CreateFormField("meta")
416 m2.Write([]byte(`{"version":"abc"}`))
417 f2, _ := mw2.CreateFormFile("file", "x")
418 f2.Write([]byte("x"))
419 mw2.Close()
420 req2, _ := http.NewRequest(http.MethodPut, fx.ts.URL+"/api/cli/system/upload", &buf2)
421 req2.Header.Set("Content-Type", mw2.FormDataContentType())
422 req2.Header.Set("Authorization", "Bearer "+owner)
423 res2, _ := http.DefaultClient.Do(req2)
424 if res2.StatusCode != http.StatusBadRequest {
425 t.Fatalf("upload without platform = %d", res2.StatusCode)
426 }
427}
428
429// Versions here are at or above release.LayoutFloor: since plan 5 the
430// rollback door judges the release it would move back to, and a version
431// that cannot be placed against the floor is refused while any box reports
432// no layout (layoutguard_test.go covers that side).
433func TestSystemStatusAndRollback(t *testing.T) {
434 fx := newConsoleFixture(t)
435 owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-status")
436 seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.25.0")
437 sg := release.RootSigner(fx.priv)
438 rel := func(v string) release.SystemRelease {
439 return release.SystemRelease{Version: v, Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey(v, "linux-arm64"), SHA256: strings.Repeat("a", 64)}}}
440 }
441 release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", rel("v0.25.0"), release.SystemPromoteOptions{})
442 time.Sleep(1100 * time.Millisecond)
443 release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", rel("v0.26.0"), release.SystemPromoteOptions{})
444 st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", owner)
445 if st != http.StatusOK {
446 t.Fatalf("status = %d %v", st, out)
447 }
448 boxes := out["boxes"].([]any)
449 if len(boxes) != 1 {
450 t.Fatalf("boxes = %v", boxes)
451 }
452 b := boxes[0].(map[string]any)
453 if b["label"] != "ie" || b["release"] != "v0.25.0" || b["target"] != "v0.26.0" || b["channel"] != "canary" {
454 t.Fatalf("box row = %v", b)
455 }
456 time.Sleep(1100 * time.Millisecond)
457 st, out = postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner)
458 if st != http.StatusOK || str(t, out, "version") != "v0.25.0" {
459 t.Fatalf("rollback = %d %v", st, out)
460 }
461 if st, out = postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner); st != http.StatusConflict {
462 t.Fatalf("second rollback = %d %v (nothing left in history)", st, out)
463 }
464 // Rollback validates what it is asked to rewind, exactly as update
465 // validates what it is asked to write — both arguments become part of an
466 // object key.
467 for _, body := range []map[string]string{{"channel": "edge"}, {"channel": ""}, {"channel": "canary", "component": "../etc"}} {
468 if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", body, owner); st != http.StatusBadRequest {
469 t.Errorf("rollback %v = %d %v, want 400", body, st, out)
470 }
471 }
472}
473
474// TestSystemFeedIsAllThreeFieldsOrNone: a half-configured feed would fetch
475// one publisher's binaries and check them against another's key, which fails
476// as "the release is corrupt" rather than as "this console is misconfigured".
477func TestSystemFeedIsAllThreeFieldsOrNone(t *testing.T) {
478 fx := newConsoleFixture(t)
479 def := publicrelease.Default()
480 full := publicrelease.Feed{LatestURL: "https://x/latest.txt", DownloadBase: "https://x/dl", PubKey: "k"}
481 for _, c := range []struct {
482 name string
483 cfg publicrelease.Feed
484 want publicrelease.Feed
485 }{
486 {"unset", publicrelease.Feed{}, def},
487 {"all three", full, full},
488 {"base only", publicrelease.Feed{DownloadBase: full.DownloadBase}, def},
489 {"base and key, no latest", publicrelease.Feed{DownloadBase: full.DownloadBase, PubKey: full.PubKey}, def},
490 {"key only", publicrelease.Feed{PubKey: full.PubKey}, def},
491 } {
492 fx.srv.cfg.SystemFeed = c.cfg
493 if got := fx.srv.systemFeed(); got != c.want {
494 t.Errorf("%s: systemFeed() = %+v, want %+v", c.name, got, c.want)
495 }
496 }
497}
498
499// TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor pins the channel
500// pointer `carlos system update --version <tag>` writes while no host floor
501// is published (the claim trial's dev pointers on v0.28.3, untagged-pointer
502// ruling R2): the release as the door resolved it, {Version, Source
503// "release:<tag>", Artifacts}, and nothing else. No release tag, no moves
504// protocol and no console artifacts, because reserveHostUpdate tags a
505// release only once a floor is published. internal/host's install-channel
506// tests and the root-runner activation witness promote exactly this shape.
507func TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor(t *testing.T) {
508 fx := newConsoleFixture(t)
509 arm := []byte("ELF arm64")
510 systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": arm})
511 seedBox(t, fx, "ie", "linux-arm64", "stable", "v0.17.0")
512 owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-untagged")
513 st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "stable", "version": "v0.18.0", "spread": "0s"}, owner)
514 if st != http.StatusOK {
515 t.Fatalf("update = %d %v", st, out)
516 }
517 if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" {
518 t.Fatalf("job failed: %v", job)
519 }
520 p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "stable")
521 if err != nil {
522 t.Fatal(err)
523 }
524 s := sha256.Sum256(arm)
525 want := release.SystemRelease{Version: "v0.18.0", Source: "release:v0.18.0", ReleasedAt: p.Current.ReleasedAt,
526 Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey("v0.18.0", "linux-arm64"), SHA256: hex.EncodeToString(s[:])}}}
527 if !reflect.DeepEqual(p.Current, want) || p.Spread != "0s" {
528 t.Fatalf("pointer current = %+v spread %s, want %+v", p.Current, p.Spread, want)
529 }
530 if _, err := release.VerifySystemIdentity(fx.pub, mustGetObject(t, fx, release.SystemPointerKey("router", "stable"))); err == nil {
531 t.Fatal("an untagged pointer verified as a release identity")
532 }
533}
534
535func mustGetObject(t *testing.T, fx *consoleFixture, key string) []byte {
536 t.Helper()
537 raw, _, err := fx.rel.Get(context.Background(), key)
538 if err != nil {
539 t.Fatal(err)
540 }
541 return raw
542}
543