package stacks import ( "bytes" "errors" "log" "path/filepath" "strings" "testing" "time" ) // v0.279.0 (decision 45) — after_install: the one command after a FRESH install that replaces a known default // login with the generated one. The exec is the seam; nothing reaches Docker. // COMPANION RED-PROOFS: (1) drop the success-marker check in runAfterInstallNow → a failing command is // recorded ok and the second case fails; (2) make expandAfterInstall allow an undeclared/empty name → the // command runs with a hole and the third case fails. func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) { m, _ := keptManager(t) var logBuf bytes.Buffer m.logger = log.New(&logBuf, "", 0) afterInstallTries, afterInstallGap = 3, 0 t.Cleanup(func() { afterInstallTries, afterInstallGap = 6, 20*time.Second }) dir := filepath.Dir(m.stacks["cloudapp"].ComposePath) must(t, SaveAppConfig(dir, &AppConfig{Deployed: true, Env: map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"}}, m.encKey, nil)) ai := &AfterInstallCommand{Service: "cloudapp", Env: []string{"ADMIN_PASSWORD"}, Command: []string{"/app/bin/tool", "set-admin", "${ADMIN_PASSWORD}"}, Success: "FELHOM_OK"} m.mu.Lock() m.stacks["cloudapp"].Meta.AfterInstall = ai m.mu.Unlock() record := func(ok bool, d string) { m.mutateAppConfig("cloudapp", dir, "after_install", func(c *AppConfig) bool { c.AfterInstall = &AfterInstallRecord{At: "t", OK: ok, Detail: d} return true }) } // 1. Success: the value is filled in, the command runs ONCE, the record says ok, the log never carries it. var calls [][]string m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args) return "... FELHOM_OK", nil } cmd, err := expandAfterInstall(ai.Command, ai.Env, map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"}) must(t, err) if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err != nil { t.Fatal(err) } if len(calls) != 1 || strings.Join(calls[0], " ") != "exec -T cloudapp /app/bin/tool set-admin Gen3r4tedValue" { t.Fatalf("ran %v", calls) } if c := LoadAppConfig(dir); c.AfterInstall == nil || !c.AfterInstall.OK { t.Fatalf("record: %+v", c.AfterInstall) } if strings.Contains(logBuf.String(), "Gen3r4tedValue") { t.Fatal("the generated password reached the log") } // 2. No success marker: retried, then recorded as FAILED — never recorded ok. calls = nil m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args) return "boom", errors.New("exit 1") } if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil { t.Fatal("a failing command reported success") } if len(calls) != 3 { t.Fatalf("tries %d, want 3", len(calls)) } if c := LoadAppConfig(dir); c.AfterInstall == nil || c.AfterInstall.OK { t.Fatalf("a failure was recorded as ok: %+v", c.AfterInstall) } // 2b. Exit 0 WITHOUT the marker (claper's `rpc` exits 0 on an Elixir error): still a failure. calls = nil m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args) return "FELHOM_AFTER_INSTALL_FAILED {:error, changeset}", nil } if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil { t.Fatal("an exit-0 command without the success marker reported success") } if c := LoadAppConfig(dir); c.AfterInstall == nil || c.AfterInstall.OK { t.Fatalf("an exit-0 failure was recorded as ok: %+v", c.AfterInstall) } // 3. A value that is not declared, or empty, refuses BEFORE anything runs (never an empty password). for _, env := range []map[string]string{{}, {"ADMIN_PASSWORD": ""}} { if _, err := expandAfterInstall(ai.Command, ai.Env, env); err == nil { t.Fatalf("expanded with env %v", env) } } if _, err := expandAfterInstall([]string{"x ${OTHER}"}, ai.Env, map[string]string{"OTHER": "v"}); err == nil { t.Fatal("an undeclared name was filled in") } } // R-713 (v0.281.0): a value pasted into CODE must not be able to end a string or start an interpolation; the // same value as its own argument, a plain argument, or ${NAME|base64} is fine. // COMPANION RED-PROOF: make argumentShaped return true always → the claper-shaped case runs with the quote. func TestR713_AValueThatWouldBeReadAsCodeIsRefused(t *testing.T) { typed := map[string]string{"ADMIN_PASSWORD": `My"pass#{System.halt()}`} allowed := []string{"ADMIN_PASSWORD"} claperShaped := []string{"/app/bin/claper", "rpc", `Claper.x(u, %{password: "${ADMIN_PASSWORD}"})`} if _, err := expandAfterInstall(claperShaped, allowed, typed); err == nil || !strings.Contains(err.Error(), "read as code") { t.Fatalf("a quote and #{ went into Elixir code: %v", err) } for _, cmd := range [][]string{ {"python3", "-c", "import sys; f(sys.argv[1])", "${ADMIN_PASSWORD}"}, // its own argument (mealie, wger) {"php", "artisan", "x", "--password=${ADMIN_PASSWORD}"}, // a plain argument (bookstack) {"python3", "cps.py", "-s", "admin:${ADMIN_PASSWORD}"}, // calibre-web {"/app/bin/claper", "rpc", `x(Base.decode64!("${ADMIN_PASSWORD|base64}"))`}, // claper, fixed } { out, err := expandAfterInstall(cmd, allowed, typed) if err != nil { t.Fatalf("%v: refused a safe placement: %v", cmd, err) } if strings.Contains(cmd[len(cmd)-1], "|base64") { if strings.ContainsAny(out[len(out)-1][len(`x(Base.decode64!("`):], `'\{}$`+"`") || strings.Contains(out[len(out)-1], `My"pass`) { t.Fatalf("base64 form leaked the raw value: %q", out[len(out)-1]) } } } if _, err := expandAfterInstall([]string{"x", "${ADMIN_PASSWORD|rot13}"}, allowed, typed); err == nil { t.Fatal("an unknown encoding was accepted") } }