eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// TestEmbeddedUnits guards the embedded systemd units against drift — the
11// instance template must carry the EnvironmentFile add-instance relies on.
12func TestEmbeddedUnits(t *testing.T) {
13 if !strings.Contains(unitRouter, "ExecStart=/opt/lchat/lchat router") {
14 t.Error("router unit missing its ExecStart line")
15 }
16 if !strings.Contains(unitInstance, "EnvironmentFile=-/data/%i.env") {
17 t.Error("instance unit missing EnvironmentFile directive")
18 }
19 // Execs the per-instance live symlink (versioned deploys), not one shared binary.
20 if !strings.Contains(unitInstance, "ExecStart=/opt/lchat/live/%i --socket /run/lchat/%i.sock --db /data/%i.db") {
21 t.Error("instance unit missing its versioned ExecStart line")
22 }
23 // The bot execs the SAME per-instance live symlink as its instance, so its
24 // binary tracks the app version (deployHostVersion restarts it on a move).
25 if !strings.Contains(unitBot, "ExecStart=/opt/lchat/live/%i bot run") {
26 t.Error("bot unit missing its versioned `bot run` ExecStart line")
27 }
28 if !strings.Contains(unitBot, "EnvironmentFile=-/data/%i-bot.env") {
29 t.Error("bot unit missing its EnvironmentFile")
30 }
31}
32
33// TestEmbeddedCollectors guards that the status collectors ride the binary: the
34// scripts + their units are embedded non-empty and match their source (the box
35// can no longer scp them in — the whole point of this install path).
36func TestEmbeddedCollectors(t *testing.T) {
37 // A sentinel line from each source script — proves we embedded the real file,
38 // not an empty/placeholder string, and pins the /opt/lchat ExecStart path the
39 // units depend on.
40 if !strings.Contains(scriptSSHLedger, "Installed on the box at exactly /opt/lchat/ssh-ledger-collect.sh") {
41 t.Error("scriptSSHLedger missing its source sentinel line")
42 }
43 if !strings.Contains(scriptHardening, "Public fleet hardening posture — the box-side collector") {
44 t.Error("scriptHardening missing its source sentinel line")
45 }
46 // Both scripts are bash — the shebang must survive the embed so systemd can
47 // exec them.
48 for name, s := range map[string]string{"ssh-ledger": scriptSSHLedger, "hardening": scriptHardening} {
49 if !strings.HasPrefix(s, "#!/usr/bin/env bash") {
50 t.Errorf("%s script missing its shebang", name)
51 }
52 }
53 // The four units + their ExecStart / timer markers.
54 for name, u := range map[string]string{
55 "ssh-ledger.service": unitSSHLedger, "ssh-ledger.timer": unitSSHLedgerTimer,
56 "hardening.service": unitHardening, "hardening.timer": unitHardeningTimer,
57 } {
58 if strings.TrimSpace(u) == "" {
59 t.Errorf("unit %s embedded empty", name)
60 }
61 }
62 if !strings.Contains(unitSSHLedger, "ExecStart=/opt/lchat/ssh-ledger-collect.sh") {
63 t.Error("ssh-ledger unit missing its ExecStart line")
64 }
65 if !strings.Contains(unitHardening, "ExecStart=/opt/lchat/hardening-posture.sh") {
66 t.Error("hardening unit missing its ExecStart line")
67 }
68}
69
70// TestWriteCollectorFiles drives the shared install helper into temp dirs (no
71// systemctl, no /opt/lchat) and asserts every file lands at the right path with
72// the right mode: scripts executable (0755), units + pages 0644, and the viewer
73// pages written basename-only into <app-dir>/status-pages.
74func TestWriteCollectorFiles(t *testing.T) {
75 appDir := t.TempDir()
76 unitDir := t.TempDir()
77 dataDir := t.TempDir()
78
79 writeCollectorFiles(appDir, unitDir, dataDir)
80
81 // The collectors' state dirs must exist before their units first start: a
82 // missing ReadWritePaths target fails the unit's namespace setup — even its
83 // `+`-prefixed ExecStartPre mkdir (the first-real-run failure, 2026-07-19).
84 for _, d := range []string{"ssh-ledger", "hardening"} {
85 info, err := os.Stat(filepath.Join(dataDir, d))
86 if err != nil || !info.IsDir() {
87 t.Errorf("collector state dir %s not created: %v", d, err)
88 }
89 }
90
91 // Scripts: present, executable, and byte-identical to the embedded source.
92 for _, tc := range []struct {
93 name string
94 want string
95 }{
96 {"ssh-ledger-collect.sh", scriptSSHLedger},
97 {"hardening-posture.sh", scriptHardening},
98 } {
99 p := filepath.Join(appDir, tc.name)
100 assertFileMode(t, p, 0o755)
101 got, err := os.ReadFile(p)
102 if err != nil {
103 t.Fatalf("read %s: %v", tc.name, err)
104 }
105 if string(got) != tc.want {
106 t.Errorf("%s content differs from embedded source", tc.name)
107 }
108 }
109
110 // Units: present at 0644 in the unit dir.
111 for _, u := range []string{
112 "lchat-ssh-ledger.service", "lchat-ssh-ledger.timer",
113 "lchat-hardening.service", "lchat-hardening.timer",
114 } {
115 assertFileMode(t, filepath.Join(unitDir, u), 0o644)
116 }
117
118 // Pages: every embedded .html + status.css, basename-only, at 0644 — and
119 // nothing else (no ssh/ dir, no JSON).
120 pagesDir := filepath.Join(appDir, "status-pages")
121 for _, page := range []string{
122 "index.html", "hardening.html", "ssh.html", "music.html",
123 "status.css", "status-nav.js",
124 } {
125 assertFileMode(t, filepath.Join(pagesDir, page), 0o644)
126 }
127 for _, unexpected := range []string{"carbon.json", "ssh-operators.json", "icon-192.png"} {
128 if _, err := os.Stat(filepath.Join(pagesDir, unexpected)); err == nil {
129 t.Errorf("status-pages should not contain generated/non-embedded %s", unexpected)
130 }
131 }
132}
133
134// TestEnableCollectorTimers checks the helper enables + fires both timers.
135func TestEnableCollectorTimers(t *testing.T) {
136 oldSysctl := systemctl
137 t.Cleanup(func() { systemctl = oldSysctl })
138 var calls []string
139 systemctl = func(args ...string) error {
140 calls = append(calls, strings.Join(args, " "))
141 return nil
142 }
143
144 enableCollectorTimers()
145
146 want := []string{
147 "enable --now lchat-ssh-ledger.timer",
148 "enable --now lchat-hardening.timer",
149 }
150 if len(calls) != len(want) {
151 t.Fatalf("systemctl calls = %v, want %v", calls, want)
152 }
153 for i, w := range want {
154 if calls[i] != w {
155 t.Errorf("call %d = %q, want %q", i, calls[i], w)
156 }
157 }
158}
159
160// TestSyncLinks: the redirector runs on the apex box and nowhere else.
161// enable+restart (never `enable --now`) so the installer's update path
162// re-execs the new binary rather than leaving the old process up; disable
163// --now on every other box so a fleet doesn't carry idle redirectors.
164func TestSyncLinks(t *testing.T) {
165 for _, tc := range []struct {
166 name string
167 apex bool
168 want []string
169 }{
170 {"apex box runs it", true, []string{"enable lchat-links", "restart lchat-links"}},
171 {"every other box does not", false, []string{"disable --now lchat-links"}},
172 } {
173 t.Run(tc.name, func(t *testing.T) {
174 oldSysctl := systemctl
175 t.Cleanup(func() { systemctl = oldSysctl })
176 var calls []string
177 systemctl = func(args ...string) error {
178 calls = append(calls, strings.Join(args, " "))
179 return nil
180 }
181
182 syncLinks(tc.apex)
183
184 if len(calls) != len(tc.want) {
185 t.Fatalf("systemctl calls = %v, want %v", calls, tc.want)
186 }
187 for i, w := range tc.want {
188 if calls[i] != w {
189 t.Errorf("call %d = %q, want %q", i, calls[i], w)
190 }
191 }
192 })
193 }
194}
195
196// TestSyncLinksSurvivesFailure: short links are one optional service, so a box
197// that can't drive the unit must still finish bootstrapping rather than die().
198func TestSyncLinksSurvivesFailure(t *testing.T) {
199 oldSysctl := systemctl
200 t.Cleanup(func() { systemctl = oldSysctl })
201 systemctl = func(args ...string) error { return os.ErrPermission }
202
203 syncLinks(true) // must return normally rather than exiting
204 syncLinks(false) // ditto for the disable path
205}
206
207// TestLinksRouted: the registry is what decides whether this box is the apex,
208// and it is matched on the SOCKET, not a hostname — that is what lets the same
209// code recognise 11ch.at in production and dev.11ch.at on the dev mesh without
210// either being named here.
211//
212// It is also what makes re-running bootstrap safe: the apex box keeps its
213// route, so a later plain `bootstrap` sees routed and leaves short links up,
214// instead of silently switching them off the way a flag-keyed design would.
215func TestLinksRouted(t *testing.T) {
216 newRegistry := func(t *testing.T, routes ...Route) string {
217 t.Helper()
218 path := filepath.Join(t.TempDir(), "registry.db")
219 reg, err := openRegistry(path)
220 if err != nil {
221 t.Fatalf("open registry: %v", err)
222 }
223 for _, rt := range routes {
224 if err := reg.upsert(rt); err != nil {
225 t.Fatalf("seed %s: %v", rt.Host, err)
226 }
227 }
228 return path
229 }
230
231 t.Run("no routes at all", func(t *testing.T) {
232 if linksRouted(newRegistry(t)) {
233 t.Error("an empty registry must not read as the apex")
234 }
235 })
236
237 t.Run("instances only", func(t *testing.T) {
238 path := newRegistry(t,
239 Route{Host: "a.example.com", Kind: "instance", Socket: "/run/lchat/a.example.com.sock", Status: "ready"},
240 Route{Host: "home.example.com", Kind: "service", Socket: "/run/lchat/home.sock", Status: "ready"},
241 )
242 if linksRouted(path) {
243 t.Error("a box with instances and other services must not read as the apex")
244 }
245 })
246
247 t.Run("production apex", func(t *testing.T) {
248 path := newRegistry(t,
249 Route{Host: "a.example.com", Kind: "instance", Socket: "/run/lchat/a.example.com.sock", Status: "ready"},
250 Route{Host: "11ch.at", Kind: "service", Socket: linksSocket, Status: "ready"},
251 )
252 if !linksRouted(path) {
253 t.Error("a route pointing at the links socket means this box IS the apex")
254 }
255 })
256
257 t.Run("dev apex — recognised without being named", func(t *testing.T) {
258 path := newRegistry(t, Route{Host: "dev.11ch.at", Kind: "service", Socket: linksSocket, Status: "ready"})
259 if !linksRouted(path) {
260 t.Error("the apex is identified by socket, so any hostname must work")
261 }
262 })
263
264 t.Run("unreadable registry is not the apex", func(t *testing.T) {
265 // Fail safe: an idle box beats starting a service we couldn't justify.
266 if linksRouted(filepath.Join(t.TempDir(), "nonexistent", "registry.db")) {
267 t.Error("an unreadable registry must not read as the apex")
268 }
269 })
270
271 // The fresh-box case, and the one that actually guards a footgun: /data
272 // exists but the registry does not yet. openRegistry CREATES the database,
273 // and this read runs as root on every plain bootstrap (including every
274 // self-hoster's install.sh) — while the router, running as the lchat user,
275 // creates the registry itself moments earlier. If this read won that race
276 // it would leave a root-owned registry the router cannot write, breaking
277 // all routing on a fresh box. So it must answer "not the apex" WITHOUT
278 // creating anything.
279 t.Run("absent registry is not the apex and is not created", func(t *testing.T) {
280 dir := t.TempDir()
281 path := filepath.Join(dir, "registry.db")
282
283 if linksRouted(path) {
284 t.Error("an absent registry must not read as the apex")
285 }
286
287 entries, err := os.ReadDir(dir)
288 if err != nil {
289 t.Fatalf("read dir: %v", err)
290 }
291 for _, e := range entries {
292 t.Errorf("linksRouted created %s — a read must never create the registry", e.Name())
293 }
294 })
295}
296
297// TestRestartControlUnitsIncludesLinks: the units that exec /opt/lchat/lchat
298// directly (rather than a versioned live symlink) keep their OLD inode until
299// something restarts them, so every one of them must ride the updater's
300// restart pass — otherwise the redirector is the one service that never picks
301// up a new build.
302func TestRestartControlUnitsIncludesLinks(t *testing.T) {
303 oldSysctl := systemctl
304 t.Cleanup(func() { systemctl = oldSysctl })
305 var calls []string
306 systemctl = func(args ...string) error {
307 calls = append(calls, strings.Join(args, " "))
308 return nil
309 }
310
311 restartControlUnits()
312
313 want := []string{
314 "try-restart lchat-control.service",
315 "try-restart lchat-cloud.service",
316 "try-restart lchat-links.service",
317 }
318 if len(calls) != len(want) {
319 t.Fatalf("systemctl calls = %v, want %v", calls, want)
320 }
321 for i, w := range want {
322 if calls[i] != w {
323 t.Errorf("call %d = %q, want %q", i, calls[i], w)
324 }
325 }
326}
327
328// TestSetUpdateTimer: the off path must DISABLE, not merely skip enabling. A
329// box bootstrapped normally once already has the timer on, and on the dev mesh
330// an updater pass would replace unmerged code with the current stable build —
331// so the end state has to follow the flag, not the box's history.
332func TestSetUpdateTimer(t *testing.T) {
333 for _, tc := range []struct {
334 name string
335 on bool
336 want string
337 }{
338 {"on enables", true, "enable --now lchat-update.timer"},
339 {"off disables", false, "disable --now lchat-update.timer"},
340 } {
341 t.Run(tc.name, func(t *testing.T) {
342 oldSysctl := systemctl
343 t.Cleanup(func() { systemctl = oldSysctl })
344 var calls []string
345 systemctl = func(args ...string) error {
346 calls = append(calls, strings.Join(args, " "))
347 return nil
348 }
349
350 setUpdateTimer(tc.on)
351
352 if len(calls) != 1 || calls[0] != tc.want {
353 t.Errorf("systemctl calls = %v, want [%q]", calls, tc.want)
354 }
355 })
356 }
357}
358
359// TestLinksUnitShape guards the embedded unit against drift: it must exec the
360// links subcommand on the socket bootstrap's route registration expects, and
361// must NOT carry an EnvironmentFile (it holds no secrets — the reason enabling
362// it everywhere is safe).
363func TestLinksUnitShape(t *testing.T) {
364 if !strings.Contains(unitLinks, "ExecStart=/opt/lchat/lchat links run --socket /run/lchat/links.sock") {
365 t.Error("links unit missing its ExecStart line (socket path is the route contract)")
366 }
367 if strings.Contains(unitLinks, "EnvironmentFile") {
368 t.Error("links unit should carry no EnvironmentFile — it holds no secrets")
369 }
370}
371
372// assertFileMode asserts a file exists with exactly the given permission bits.
373func assertFileMode(t *testing.T, path string, want os.FileMode) {
374 t.Helper()
375 fi, err := os.Stat(path)
376 if err != nil {
377 t.Fatalf("stat %s: %v", path, err)
378 }
379 if got := fi.Mode().Perm(); got != want {
380 t.Errorf("%s mode = %o, want %o", path, got, want)
381 }
382}
383
384// TestOperatorsExist pins the lockdown gate: only a group file whose
385// lchat-operators entry actually lists members opens it — a missing file,
386// missing group, or empty member list must all read as "no operators".
387func TestOperatorsExist(t *testing.T) {
388 cases := map[string]struct {
389 content string
390 want bool
391 }{
392 "members": {"root:x:0:\nlchat-operators:x:990:paul,james\n", true},
393 "one member": {"lchat-operators:x:990:paul\n", true},
394 "empty list": {"lchat-operators:x:990:\n", false},
395 "blank list": {"lchat-operators:x:990: \n", false},
396 "no group": {"root:x:0:\nlchat:x:999:\n", false},
397 "name is prefix only": {"lchat-operators-old:x:991:paul\n", false},
398 }
399 for name, tc := range cases {
400 p := filepath.Join(t.TempDir(), "group")
401 if err := os.WriteFile(p, []byte(tc.content), 0o644); err != nil {
402 t.Fatal(err)
403 }
404 if got := operatorsExist(p); got != tc.want {
405 t.Errorf("%s: operatorsExist = %v, want %v", name, got, tc.want)
406 }
407 }
408 if operatorsExist(filepath.Join(t.TempDir(), "missing")) {
409 t.Error("missing group file should read as no operators")
410 }
411}
412
413// TestNoRootDropIn guards the drop-in contract: the directive itself, and the
414// filename ordering that makes it effective — sshd config is first-match-wins,
415// so the file must sort before the distro's 50-*.conf (prohibit-password).
416func TestNoRootDropIn(t *testing.T) {
417 if !strings.Contains(noRootConf, "PermitRootLogin no\n") {
418 t.Error("drop-in missing its PermitRootLogin no directive")
419 }
420 base := filepath.Base(noRootConfPath)
421 if base >= "50-" {
422 t.Errorf("drop-in %s sorts after 50-*.conf — it would lose first-match to the distro default", base)
423 }
424 if filepath.Dir(noRootConfPath) != "/etc/ssh/sshd_config.d" {
425 t.Errorf("drop-in in %s, want /etc/ssh/sshd_config.d", filepath.Dir(noRootConfPath))
426 }
427}
428
429// TestApplyNoRootSSH covers the converge flow with the exec seams stubbed:
430// fresh write reloads sshd, an already-correct file is a silent no-op, a
431// failed validation restores exactly the prior state (file absent or prior
432// content) so sshd's next real restart can never hit config it would refuse,
433// and a drop-in that fails the EFFECTIVE-config check errors on both the
434// fresh and idempotent paths (the kepler 00-hardening.conf shape).
435func TestApplyNoRootSSH(t *testing.T) {
436 oldCheck, oldSysctl, oldEff := sshdCheck, systemctl, sshdRootLogin
437 defer func() { sshdCheck, systemctl, sshdRootLogin = oldCheck, oldSysctl, oldEff }()
438
439 var reloads []string
440 systemctl = func(args ...string) error {
441 reloads = append(reloads, strings.Join(args, " "))
442 return nil
443 }
444 sshdCheck = func() error { return nil }
445 sshdRootLogin = func() (string, error) { return "no", nil }
446
447 p := filepath.Join(t.TempDir(), "10-lchat-noroot.conf")
448 changed, err := applyNoRootSSH(p)
449 if err != nil || !changed {
450 t.Fatalf("fresh apply: changed=%v err=%v, want true, nil", changed, err)
451 }
452 if b, _ := os.ReadFile(p); string(b) != noRootConf {
453 t.Errorf("drop-in content = %q", b)
454 }
455 if len(reloads) != 1 || reloads[0] != "reload ssh" {
456 t.Errorf("reloads = %v, want [reload ssh]", reloads)
457 }
458
459 // Second pass: already converged — no write, no reload.
460 reloads = nil
461 if changed, err = applyNoRootSSH(p); err != nil || changed {
462 t.Fatalf("idempotent apply: changed=%v err=%v, want false, nil", changed, err)
463 }
464 if len(reloads) != 0 {
465 t.Errorf("idempotent apply reloaded sshd: %v", reloads)
466 }
467
468 // Validation failure over an existing (stale) file restores it verbatim.
469 stale := "PermitRootLogin prohibit-password\n"
470 if err := os.WriteFile(p, []byte(stale), 0o644); err != nil {
471 t.Fatal(err)
472 }
473 sshdCheck = func() error { return os.ErrInvalid }
474 if _, err = applyNoRootSSH(p); err == nil {
475 t.Fatal("apply with failing sshd -t should error")
476 }
477 if b, _ := os.ReadFile(p); string(b) != stale {
478 t.Errorf("failed validation left %q, want prior content restored", b)
479 }
480
481 // Validation failure on a fresh box removes the file entirely.
482 p2 := filepath.Join(t.TempDir(), "10-lchat-noroot.conf")
483 if _, err = applyNoRootSSH(p2); err == nil {
484 t.Fatal("apply with failing sshd -t should error")
485 }
486 if _, statErr := os.Stat(p2); !os.IsNotExist(statErr) {
487 t.Errorf("failed validation left the drop-in behind (stat err %v)", statErr)
488 }
489
490 // An earlier-sorting file overriding the drop-in (effective config stays
491 // prohibit-password): the fresh write must error — success here is the
492 // kepler bug — and the drop-in stays in place (inert, not harmful) for the
493 // re-run after the operator fixes the ordering.
494 sshdCheck = func() error { return nil }
495 sshdRootLogin = func() (string, error) { return "prohibit-password", nil }
496 p3 := filepath.Join(t.TempDir(), "10-lchat-noroot.conf")
497 if _, err = applyNoRootSSH(p3); err == nil || !strings.Contains(err.Error(), "first-match-wins") {
498 t.Fatalf("ineffective drop-in: err = %v, want first-match-wins explanation", err)
499 }
500 if b, _ := os.ReadFile(p3); string(b) != noRootConf {
501 t.Errorf("ineffective drop-in should stay in place, got %q", b)
502 }
503 // Same on the idempotent path: an already-correct file that isn't winning
504 // must not report success.
505 if _, err = applyNoRootSSH(p3); err == nil {
506 t.Fatal("idempotent apply over an ineffective drop-in should error")
507 }
508}
509
510func TestCountAuthorizedKeys(t *testing.T) {
511 cases := []struct {
512 name string
513 in string
514 n int
515 ok bool
516 }{
517 {"one ed25519", "ssh-ed25519 AAAAC3Nza james@mac\n", 1, true},
518 {"two with comments and blanks", "# ci key\nssh-ed25519 AAAA a\n\nsk-ssh-ed25519@openssh.com BBBB b\n", 2, true},
519 {"ecdsa", "ecdsa-sha2-nistp256 AAAA\n", 1, true},
520 {"empty", "", 0, false},
521 {"whitespace only", "\n \n", 0, false},
522 {"junk line", "ssh-ed25519 AAAA ok\nnot a key\n", 0, false},
523 {"missing key body", "ssh-ed25519\n", 0, false},
524 }
525 for _, c := range cases {
526 n, err := countAuthorizedKeys([]byte(c.in))
527 if (err == nil) != c.ok || n != c.n {
528 t.Errorf("%s: n=%d err=%v, want n=%d ok=%v", c.name, n, err, c.n, c.ok)
529 }
530 }
531}
532
533// TestAddOperatorAccount drives the whole verb through the exec/user seams:
534// the create path must run useradd, the converge path must not, and a bad
535// keys pipe must fail BEFORE any account command runs — with root SSH off, a
536// clobbered authorized_keys is a console/rescue trip.
537func TestAddOperatorAccount(t *testing.T) {
538 oldRun, oldLookup := runAccountCmd, lookupAccount
539 defer func() { runAccountCmd, lookupAccount = oldRun, oldLookup }()
540
541 home := t.TempDir()
542 exists := false
543 var calls []string
544 runAccountCmd = func(name string, args ...string) error {
545 calls = append(calls, name+" "+strings.Join(args, " "))
546 if name == "useradd" {
547 exists = true
548 }
549 return nil
550 }
551 lookupAccount = func(string) (string, int, int, bool) {
552 return home, os.Getuid(), os.Getgid(), exists
553 }
554
555 keys := "ssh-ed25519 AAAA james@mac\nssh-ed25519 BBBB james@laptop\n"
556 if err := addOperatorAccount("james", strings.NewReader(keys)); err != nil {
557 t.Fatalf("create: %v", err)
558 }
559 want := []string{"groupadd -f lchat-operators", "useradd -m -s /bin/bash james",
560 "usermod -aG lchat-operators,systemd-journal james"}
561 if strings.Join(calls, ";") != strings.Join(want, ";") {
562 t.Errorf("create calls = %v, want %v", calls, want)
563 }
564 ak := filepath.Join(home, ".ssh", "authorized_keys")
565 if b, err := os.ReadFile(ak); err != nil || string(b) != keys {
566 t.Errorf("authorized_keys = %q, %v", b, err)
567 }
568 for p, want := range map[string]os.FileMode{filepath.Join(home, ".ssh"): 0o700, ak: 0o600} {
569 if fi, err := os.Stat(p); err != nil || fi.Mode().Perm() != want {
570 t.Errorf("%s mode = %v, want %v", p, fi.Mode().Perm(), want)
571 }
572 }
573
574 // Converge an existing account with a rotated key set: no useradd, and
575 // the file is REPLACED, not appended.
576 calls = nil
577 rotated := "ssh-ed25519 CCCC james@new\n"
578 if err := addOperatorAccount("james", strings.NewReader(rotated)); err != nil {
579 t.Fatalf("converge: %v", err)
580 }
581 for _, c := range calls {
582 if strings.HasPrefix(c, "useradd") {
583 t.Errorf("converge ran useradd: %v", calls)
584 }
585 }
586 if b, _ := os.ReadFile(ak); string(b) != rotated {
587 t.Errorf("rotated authorized_keys = %q, want replacement", b)
588 }
589
590 // Bad pipes and bad names fail before any account command.
591 calls = nil
592 if err := addOperatorAccount("james", strings.NewReader("")); err == nil {
593 t.Error("empty stdin should error")
594 }
595 if err := addOperatorAccount("James Adam", strings.NewReader(rotated)); err == nil {
596 t.Error("invalid account name should error")
597 }
598 if len(calls) != 0 {
599 t.Errorf("failed runs still executed commands: %v", calls)
600 }
601}
602