carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "encoding/json"
8 "fmt"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "slices"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/release"
18)
19
20func TestDigestCell(t *testing.T) {
21 if digestCell(true, false) != textStateYes || digestCell(false, false) != textStateNo || digestCell(false, true) != textStateStraggler {
22 t.Fatal("digestCell does not map the three states to their approved texts")
23 }
24}
25
26// directDigestStore is a deployment dir with the root key in the env: the
27// shape `carlos` takes with no console. CARLOS_DEPLOYMENT_DIR selects
28// direct-store mode (resolveMode, apimode.go:104); HOME and
29// CARLOS_CREDENTIALS are masked so no real login is ever consulted.
30func directDigestStore(t *testing.T) (release.Store, ed25519.PublicKey) {
31 t.Helper()
32 dir := t.TempDir()
33 seed, pubB64, err := release.GenerateKey()
34 if err != nil {
35 t.Fatal(err)
36 }
37 t.Setenv("HOME", t.TempDir())
38 t.Setenv("CARLOS_CREDENTIALS", "")
39 t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
40 t.Setenv("CARLOS_RELEASE_KEY", seed)
41 t.Setenv("CARLOS_RELEASE_PUBKEY", pubB64)
42 priv, _ := release.ParseSigningKey(seed, "test")
43 pub, _ := release.ParsePublicKey(pubB64, "test")
44 s := release.NewDirStore(dir)
45 m := release.Manifest{Schema: 1, App: "km", Version: "v1", Kind: "binary",
46 Artifacts: map[string]release.Artifact{"linux-amd64": {}}}
47 if err := release.Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-amd64": []byte("one")}); err != nil {
48 t.Fatal(err)
49 }
50 if err := release.Promote(context.Background(), s, priv, "bab", "km", "stable", "v1"); err != nil {
51 t.Fatal(err)
52 }
53 // Resign needs the verified marker (Astra I3 #4); written after the
54 // promotion, so the pointer still has no digests to resign.
55 if err := release.WriteDigestMarker(context.Background(), s, release.RootSigner(priv), "bab", "test"); err != nil {
56 t.Fatal(err)
57 }
58 return s, pub
59}
60
61func runResign(t *testing.T, answer string, args ...string) string {
62 t.Helper()
63 var out bytes.Buffer
64 prevOut, prevIn := stdout, resignInput
65 stdout, resignInput = &out, strings.NewReader(answer)
66 t.Cleanup(func() { stdout, resignInput = prevOut, prevIn })
67 runChannelsResign(args)
68 return out.String()
69}
70
71// Direct-store resign: the plan is printed, "no" signs nothing, "yes" signs.
72func TestResignDirectStoreAsksBeforeSigning(t *testing.T) {
73 s, pub := directDigestStore(t)
74 ctx := context.Background()
75 out := runResign(t, "no\n", "--account-id", "bab", "--app", "km", "stable")
76 if !strings.Contains(out, "v1") || !strings.Contains(out, textResignAborted) {
77 t.Fatalf("answering no:\n%s", out)
78 }
79 if p, _ := release.Channel(ctx, s, pub, "bab", "km", "stable"); p.Digest != "" {
80 t.Fatal("answering no signed the pointer")
81 }
82 out = runResign(t, "yes\n", "--account-id", "bab", "km", "stable") // the spec's positional form
83 if p, _ := release.Channel(ctx, s, pub, "bab", "km", "stable"); p.Digest == "" {
84 t.Fatalf("answering yes did not sign:\n%s", out)
85 }
86}
87
88// The DIGESTS column through a console: yes, plain no, and the straggler
89// text, one row each; an older console's rows (no keys) read as no.
90func TestChannelsVerbAPIModeDigestsColumn(t *testing.T) {
91 useTempCredentials(t)
92 clearDeploymentEnv(t)
93 base := "/api/cli/apps/" + apiTestAccount + "/titogo"
94 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95 switch r.URL.Path {
96 case base + "/channels":
97 json.NewEncoder(w).Encode(map[string]any{"channels": []map[string]any{
98 {"channel": "stable", "version": "1", "promoted_at": "2026-08-04T00:00:00Z", "digests": true},
99 {"channel": "beta", "version": "2", "promoted_at": "2026-08-04T00:00:00Z", "straggler": true},
100 {"channel": "edge", "version": "3", "promoted_at": "2026-08-04T00:00:00Z"},
101 {"channel": "broken", "error": "signature mismatch"},
102 }})
103 case base + "/tags":
104 json.NewEncoder(w).Encode(map[string]any{"tags": []map[string]any{}})
105 default:
106 t.Errorf("path = %s", r.URL.Path)
107 }
108 }))
109 defer ts.Close()
110 loginTo(t, ts.URL, "vicky@keymail.dev")
111
112 buf := captureStdout(t)
113 runChannels([]string{"-app", "titogo", "-account", apiTestAccount})
114
115 rows := map[string]string{}
116 header := false
117 wantHeader := strings.Fields(strings.ReplaceAll(textChannelsHeader, "\t", " "))
118 for _, line := range strings.Split(buf.String(), "\n") {
119 f := strings.Fields(line)
120 if slices.Equal(f, wantHeader) {
121 header = true
122 continue
123 }
124 if len(f) > 0 {
125 rows[f[0]] = strings.TrimSpace(line)
126 }
127 }
128 if !header {
129 t.Fatalf("no %q header in:\n%s", textChannelsHeader, buf.String())
130 }
131 for ch, want := range map[string]string{"stable": textStateYes, "beta": textStateStraggler, "edge": textStateNo} {
132 if !strings.HasSuffix(rows[ch], want) {
133 t.Fatalf("%s row = %q, want it to end in %q", ch, rows[ch], want)
134 }
135 }
136 if !strings.Contains(rows["broken"], "ERROR: signature mismatch") {
137 t.Fatalf("broken row = %q", rows["broken"])
138 }
139}
140
141// `carlos channels digests` through a console: the report is read with a GET,
142// and on posts the named accounts.
143func TestChannelsDigestsAPIMode(t *testing.T) {
144 useTempCredentials(t)
145 clearDeploymentEnv(t)
146 var posted map[string]any
147 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
148 if r.URL.Path != "/api/cli/system/pointer-digests" {
149 t.Errorf("path = %s", r.URL.Path)
150 }
151 if r.Method == http.MethodPost {
152 json.NewDecoder(r.Body).Decode(&posted)
153 }
154 json.NewEncoder(w).Encode(map[string]any{
155 "accounts": []map[string]any{
156 {"account": "bab", "on": true, "since": "2026-10-07T00:00:00Z"},
157 {"account": "bah", "on": false},
158 },
159 "channels": []map[string]any{
160 {"account": "bab", "app": "km", "channel": "stable", "version": "v1", "straggler": true},
161 {"account": "bab", "app": "km", "channel": "beta", "version": "v2"},
162 },
163 })
164 }))
165 defer ts.Close()
166 loginTo(t, ts.URL, "vicky@keymail.dev")
167
168 buf := captureStdout(t)
169 runChannelsDigests(nil)
170 out := buf.String()
171 for _, want := range []string{
172 fmt.Sprintf(textStatusOn, "bab", "2026-10-07T00:00:00Z"),
173 fmt.Sprintf(textStatusOff, "bah"),
174 strings.Split(textStatusHeader, "\t")[0], textStateStraggler, "beta",
175 } {
176 if !strings.Contains(out, want) {
177 t.Fatalf("listing lacks %q:\n%s", want, out)
178 }
179 }
180 if posted != nil {
181 t.Fatalf("a bare listing posted %v", posted)
182 }
183
184 buf.Reset()
185 runChannelsDigests([]string{"on", "--account-id", "bab"})
186 if posted["on"] != true || len(posted["accounts"].([]any)) != 1 {
187 t.Fatalf("posted = %v", posted)
188 }
189 if want := fmt.Sprintf(textTurnedOn, "bab"); !strings.Contains(buf.String(), want) {
190 t.Fatalf("on output lacks %q:\n%s", want, buf.String())
191 }
192}
193
194// Resign through a console: the plan is posted with only the channel, then
195// "yes" posts the same digests back to be signed; "no" posts nothing more.
196func TestResignAPIMode(t *testing.T) {
197 useTempCredentials(t)
198 clearDeploymentEnv(t)
199 path := "/api/cli/apps/" + apiTestAccount + "/titogo/channels/resign"
200 var bodies []map[string]any
201 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
202 if r.URL.Path != path || r.Method != http.MethodPost {
203 t.Errorf("request = %s %s", r.Method, r.URL.Path)
204 }
205 var b map[string]any
206 json.NewDecoder(r.Body).Decode(&b)
207 bodies = append(bodies, b)
208 if _, signing := b["digest"]; signing {
209 json.NewEncoder(w).Encode(map[string]string{"result": "resigned"})
210 return
211 }
212 json.NewEncoder(w).Encode(map[string]any{
213 "channel": "stable", "version": "v2", "digest": strings.Repeat("a", 64),
214 "history": []string{"v1"}, "history_digests": []string{""}, "computed": []bool{true, false},
215 })
216 }))
217 defer ts.Close()
218 loginTo(t, ts.URL, "vicky@keymail.dev")
219
220 var out bytes.Buffer
221 prevIn := resignInput
222 t.Cleanup(func() { resignInput = prevIn })
223 captured := captureStdout(t)
224
225 resignInput = strings.NewReader("no\n")
226 runChannelsResign([]string{"--app", "titogo", "--account", apiTestAccount, "stable"})
227 if len(bodies) != 1 || !strings.Contains(captured.String(), textResignAborted) {
228 t.Fatalf("answering no: %d requests\n%s", len(bodies), captured.String())
229 }
230
231 captured.Reset()
232 bodies = nil
233 resignInput = strings.NewReader("yes\n")
234 runChannelsResign([]string{"--app", "titogo", "--account", apiTestAccount, "stable"})
235 out.WriteString(captured.String())
236 if len(bodies) != 2 {
237 t.Fatalf("answering yes: %d requests, want a plan and a signing\n%s", len(bodies), out.String())
238 }
239 if bodies[1]["digest"] != strings.Repeat("a", 64) || bodies[1]["channel"] != "stable" {
240 t.Fatalf("signing body = %v", bodies[1])
241 }
242 for _, want := range []string{textResignComputed, textResignMissing, fmt.Sprintf(textResignDone, "titogo", "stable")} {
243 if !strings.Contains(out.String(), want) {
244 t.Fatalf("output lacks %q:\n%s", want, out.String())
245 }
246 }
247}
248
249// An account whose report the console could not read is "unknown", never
250// "off", and never earns the none-left line.
251func TestChannelsDigestsAPIModeFailedAccount(t *testing.T) {
252 useTempCredentials(t)
253 clearDeploymentEnv(t)
254 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
255 json.NewEncoder(w).Encode(map[string]any{
256 "accounts": []map[string]any{
257 {"account": "bab", "on": true, "since": "2026-10-07T00:00:00Z"},
258 {"account": "bah", "on": false, "error": "marker did not verify"},
259 },
260 "channels": []map[string]any{},
261 })
262 }))
263 defer ts.Close()
264 loginTo(t, ts.URL, "vicky@keymail.dev")
265
266 buf := captureStdout(t)
267 runChannelsDigests(nil)
268 out := buf.String()
269 if want := fmt.Sprintf(textStatusError, "bah", "marker did not verify"); !strings.Contains(out, want) {
270 t.Fatalf("output lacks %q:\n%s", want, out)
271 }
272 if strings.Contains(out, fmt.Sprintf(textStatusOff, "bah")) || strings.Contains(out, textStatusNoneLeft) {
273 t.Fatalf("a failed account read as off, or the none-left line printed:\n%s", out)
274 }
275 if !strings.Contains(out, fmt.Sprintf(textStatusOn, "bab", "2026-10-07T00:00:00Z")) {
276 t.Fatalf("the healthy account is missing:\n%s", out)
277 }
278}
279
280func TestCheckNamedAccounts(t *testing.T) {
281 rep := apiPointerDigests{Accounts: []apiDigestAccount{{Account: "bab"}, {Account: "bah"}}}
282 if err := checkNamedAccounts(rep, []string{"bab", "bah"}); err != nil {
283 t.Fatalf("known accounts: %v", err)
284 }
285 if err := checkNamedAccounts(rep, nil); err != nil {
286 t.Fatalf("no names: %v", err)
287 }
288 err := checkNamedAccounts(rep, []string{"bab", "zzz"})
289 if err == nil || err.Error() != fmt.Sprintf(textUnknownAccount, "zzz") {
290 t.Fatalf("unknown account: %v", err)
291 }
292}
293
294func runDigests(t *testing.T, args ...string) string {
295 t.Helper()
296 buf := captureStdout(t)
297 runChannelsDigests(args)
298 return buf.String()
299}
300
301// Direct-store `channels digests`: bare lists, off removes the marker, on
302// writes it again (an account with no apps still reads on), and an account
303// whose marker cannot be verified is reported unknown.
304func TestChannelsDigestsDirectStore(t *testing.T) {
305 s, pub := directDigestStore(t) // account bab: marker on, km/stable at v1 with no digest
306 ctx := context.Background()
307 m, err := release.ReadDigestMarker(ctx, s, pub, "bab")
308 if err != nil || m == nil {
309 t.Fatalf("marker: %v %v", m, err)
310 }
311 since := m.At.UTC().Format(time.RFC3339)
312
313 out := runDigests(t, "--account-id", "bab")
314 for _, want := range []string{fmt.Sprintf(textStatusOn, "bab", since), "km", "stable", "v1", textStateNo} {
315 if !strings.Contains(out, want) {
316 t.Fatalf("listing lacks %q:\n%s", want, out)
317 }
318 }
319 if strings.Contains(out, textStatusNoneLeft) {
320 t.Fatalf("none-left printed with a channel still without digests:\n%s", out)
321 }
322
323 out = runDigests(t, "off", "--account-id", "bab")
324 if !strings.Contains(out, fmt.Sprintf(textTurnedOff, "bab")) || !strings.Contains(out, fmt.Sprintf(textStatusOff, "bab")) {
325 t.Fatalf("off:\n%s", out)
326 }
327 if m, _ := release.ReadDigestMarker(ctx, s, pub, "bab"); m != nil {
328 t.Fatal("off left the marker in place")
329 }
330
331 out = runDigests(t, "on", "--account-id", "bab", "--account-id", "bah")
332 if !strings.Contains(out, fmt.Sprintf(textTurnedOn, "bab, bah")) {
333 t.Fatalf("on:\n%s", out)
334 }
335 if m, _ := release.ReadDigestMarker(ctx, s, pub, "bab"); m == nil {
336 t.Fatal("on did not write the marker")
337 }
338 // bah has no apps at all: on, straight after turning it on.
339 bah, err := release.ReadDigestMarker(ctx, s, pub, "bah")
340 if err != nil || bah == nil {
341 t.Fatalf("on did not write bah's marker: %v %v", bah, err)
342 }
343 if want := fmt.Sprintf(textStatusOn, "bah", bah.At.UTC().Format(time.RFC3339)); !strings.Contains(out, want) {
344 t.Fatalf("an account with no apps does not read on (want %q):\n%s", want, out)
345 }
346
347 // An unverifiable marker is unknown, and the none-left line stays away.
348 if _, err := s.Put(ctx, release.PointerDigestsKey("bai"), []byte("junk"), ""); err != nil {
349 t.Fatal(err)
350 }
351 _, merr := release.ReadDigestMarker(ctx, s, pub, "bai")
352 if merr == nil {
353 t.Fatal("control: the junk marker verified")
354 }
355 out = runDigests(t, "--account-id", "bai")
356 if want := fmt.Sprintf(textStatusError, "bai", merr); !strings.Contains(out, want) {
357 t.Fatalf("unverifiable marker (want %q):\n%s", want, out)
358 }
359 if strings.Contains(out, textStatusNoneLeft) || strings.Contains(out, fmt.Sprintf(textStatusOff, "bai")) {
360 t.Fatalf("unverifiable marker read as off or done:\n%s", out)
361 }
362}
363
364// Astra r1 #4, direct-store: every readable channel carries digests, but one
365// pointer cannot be verified. The account is reported unknown with that
366// channel's error, and the none-left line never prints.
367func TestChannelsDigestsDirectStoreUnreadableChannel(t *testing.T) {
368 s, pub := directDigestStore(t) // bab: marker on, km/stable at v1 with no digest
369 ctx := context.Background()
370 priv, err := release.ParseSigningKey(os.Getenv("CARLOS_RELEASE_KEY"), "test")
371 if err != nil {
372 t.Fatal(err)
373 }
374 if err := release.Promote(ctx, s, priv, "bab", "km", "beta", "v1"); err != nil { // under the marker: digests
375 t.Fatal(err)
376 }
377 key := release.ChannelKey("bab", "km", "stable")
378 _, etag, err := s.Get(ctx, key)
379 if err != nil {
380 t.Fatal(err)
381 }
382 if _, err := s.Put(ctx, key, []byte("junk"), etag); err != nil {
383 t.Fatal(err)
384 }
385 rows, _, err := release.DigestReport(ctx, s, pub, "bab", "km")
386 if err != nil {
387 t.Fatal(err)
388 }
389 cause := ""
390 for _, r := range rows {
391 switch {
392 case r.Channel == "stable":
393 cause = r.Err
394 case !r.HeadDigest:
395 t.Fatalf("control: %s carries no digest, so none-left would be withheld anyway", r.Channel)
396 }
397 }
398 if cause == "" {
399 t.Fatal("control: the junk pointer read as a channel state")
400 }
401 out := runDigests(t, "--account-id", "bab")
402 if want := fmt.Sprintf(textStatusError, "bab", cause); !strings.Contains(out, want) {
403 t.Fatalf("output lacks %q:\n%s", want, out)
404 }
405 if strings.Contains(out, textStatusNoneLeft) {
406 t.Fatalf("an incomplete report printed the none-left line:\n%s", out)
407 }
408}
409