Files
summercms/modules/sunrise/script_behavior_test.go
Jakub Zych 9b3803c098 feat(sunrise): update an application in place with summercms.io/update
- new.sh gains --update, run inside one application directory (go.mod and
  summer.yaml required); it changes only that application
- the framework checkout is read from the app's go.mod replace (single-line
  or block form); module-version replaces, missing targets and foreign or
  dirty checkouts are refused through the shared check_framework_dir
- a detached checkout moves fast-forward only to the newest release (or
  SUMMERCMS_REF) through the shared move_detached; a branch checkout is
  pulled with git pull --no-rebase --ff-only, refusing a missing upstream
  or a diverged branch with the checkout unchanged
- other sm-*-app directories on the same checkout are named on one
  "not rebuilt" line and never built or written
- summer is reinstalled, a missing SUMMER_ADMIN__JWT__SECRET is appended to
  this app's .env (openssl, else head and base64; mode kept, never printed),
  then summer build runs and the script ends with the migrate and restart
  reminder and the shared path_hint
- sunrise.UpdateScript() serves the same script with its last line changed
  to main "$@" --update, so truncated downloads still run nothing
- check-phase14.3.sh --forbidden now allows only the literal
  SUMMER_ADMIN__JWT__SECRET in new.sh and still fails on any other
  SUMMER_ name or on a read or export of one; TestUpdateScript and
  TestUpdateScriptRoute are required tests
2026-10-08 20:15:50 +02:00

1322 lines
47 KiB
Go

package sunrise
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
// scriptHarness runs new.sh with bash on a PATH that holds only a symlink to
// the real git plus stub go and docker commands. The go stub answers
// `go env GOVERSION|GOBIN|GOPATH` from the harness and, for
// `go -C <dir> install ./cmd/summer`, writes a stub summer into the Go bin
// directory that records its working directory, arguments and stdin.
type scriptHarness struct {
t *testing.T
bash string
script string
updateFile string // UpdateScript() bytes
root string // per-case temp root
work string // the directory the script runs in
tools string // the PATH directory
logs string // stub call records
gobin string
gopath string
remote *fakeRemote
goVersion string // go env GOVERSION
useGOBIN bool // go env GOBIN prints gobin; otherwise empty, GOPATH/bin is used
gobinOnPath bool
unsetShell bool // run the script with SHELL unset instead of the empty default
env []string // extra environment
}
type scriptResult struct {
code int
stdout, stderr string
}
// stdinProbe is piped into every run: the script must never let a child
// read it.
const stdinProbe = "SHOULD-NOT-BE-READ\n"
// fwModule is the framework module path the script requires.
const fwModule = "git.golem15.com/golem15/summercms"
// gitTestEnv isolates git from the user's configuration and supplies an
// identity for the commits the harness itself makes.
func gitTestEnv(home string) []string {
return []string{
"HOME=" + home,
"XDG_CONFIG_HOME=" + filepath.Join(home, ".config"),
"GIT_CONFIG_NOSYSTEM=1",
"GIT_CONFIG_GLOBAL=" + os.DevNull,
// Detached auto maintenance would write into temp repos during cleanup.
"GIT_CONFIG_COUNT=2",
"GIT_CONFIG_KEY_0=maintenance.auto", "GIT_CONFIG_VALUE_0=false",
"GIT_CONFIG_KEY_1=gc.auto", "GIT_CONFIG_VALUE_1=0",
"GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com",
"LC_ALL=C",
}
}
// fakeRemote is a bare framework repository fed from a work clone.
type fakeRemote struct {
t *testing.T
env []string
bare string
src string
n int
tags map[string]string // tag -> commit
}
func (r *fakeRemote) git(dir string, args ...string) string {
r.t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ()[:0:0], append([]string{"PATH=" + os.Getenv("PATH")}, r.env...)...)
out, err := cmd.CombinedOutput()
if err != nil {
r.t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out)
}
return strings.TrimSpace(string(out))
}
// commit adds a commit on main and returns its hash.
func (r *fakeRemote) commit() string {
r.t.Helper()
r.n++
if err := os.WriteFile(filepath.Join(r.src, "VERSION"), []byte(strings.Repeat("x", r.n)+"\n"), 0o644); err != nil {
r.t.Fatal(err)
}
r.git(r.src, "add", "-A")
r.git(r.src, "commit", "-q", "-m", "commit")
r.git(r.src, "push", "-q", "origin", "main")
return r.git(r.src, "rev-parse", "HEAD")
}
// tag commits and tags the new commit, then pushes the tag.
func (r *fakeRemote) tag(tag string) string {
r.t.Helper()
c := r.commit()
r.git(r.src, "tag", tag)
r.git(r.src, "push", "-q", "origin", "refs/tags/"+tag)
r.tags[tag] = c
return c
}
// moveTag recreates tag on a new commit upstream.
func (r *fakeRemote) moveTag(tag string) string {
r.t.Helper()
c := r.commit()
r.git(r.src, "tag", "-f", tag)
r.git(r.src, "push", "-q", "--force", "origin", "refs/tags/"+tag)
r.tags[tag] = c
return c
}
func newFakeRemote(t *testing.T, root string, env []string) *fakeRemote {
t.Helper()
r := &fakeRemote{t: t, env: env, bare: filepath.Join(root, "remote.git"), src: filepath.Join(root, "remote-src"), tags: map[string]string{}}
r.git(root, "init", "-q", "--bare", "--initial-branch=main", r.bare)
r.git(root, "init", "-q", "--initial-branch=main", r.src)
if err := os.WriteFile(filepath.Join(r.src, "go.mod"), []byte("module "+fwModule+"\n\ngo 1.27.0\n"), 0o644); err != nil {
t.Fatal(err)
}
r.git(r.src, "remote", "add", "origin", r.bare)
r.git(r.src, "add", "-A")
r.git(r.src, "commit", "-q", "-m", "init")
r.git(r.src, "push", "-q", "origin", "main")
r.tag("v0.1.0")
r.tag("v0.2.0")
r.tag("v0.10.0")
// A pre-release newer than every release: never selected.
r.tag("v1.0.0-rc1")
return r
}
func newScriptHarness(t *testing.T) *scriptHarness {
t.Helper()
bash := lookBash(t)
gitBin, err := exec.LookPath("git")
if err != nil {
t.Skip("git not installed")
}
gitBin, err = filepath.Abs(gitBin)
if err != nil {
t.Fatal(err)
}
chmod, err := exec.LookPath("chmod")
if err != nil {
t.Skip("chmod not found")
}
root := t.TempDir()
h := &scriptHarness{
t: t,
bash: bash,
root: root,
work: filepath.Join(root, "work"),
tools: filepath.Join(root, "tools"),
logs: filepath.Join(root, "logs"),
gobin: filepath.Join(root, "gobin"),
gopath: filepath.Join(root, "gopath"),
goVersion: "go1.27.0",
useGOBIN: true,
}
for _, d := range []string{h.work, h.tools, h.logs, h.gobin, filepath.Join(h.gopath, "bin"), filepath.Join(root, "home")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
h.script = filepath.Join(root, "new.sh")
if err := os.WriteFile(h.script, Script(), 0o644); err != nil {
t.Fatal(err)
}
h.updateFile = filepath.Join(root, "update.sh")
if err := os.WriteFile(h.updateFile, UpdateScript(), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(gitBin, filepath.Join(h.tools, "git")); err != nil {
t.Fatal(err)
}
// Update mode makes the admin token secret with head and base64 when
// openssl is missing; a case adds an openssl stub with stubOpenssl.
for _, tool := range []string{"head", "base64"} {
p, err := exec.LookPath(tool)
if err != nil {
t.Skip(tool + " not installed")
}
if p, err = filepath.Abs(p); err != nil {
t.Fatal(err)
}
if err := os.Symlink(p, filepath.Join(h.tools, tool)); err != nil {
t.Fatal(err)
}
}
summerStub := "#!" + bash + `
{ printf 'cwd=%s\n' "$(pwd)"; printf 'arg=%s\n' "$@"; } >"$STUB_LOG/summer.call"
data=""
while IFS= read -r line || [[ -n $line ]]; do data+="$line"; done
printf '%s' "$data" >"$STUB_LOG/summer.stdin"
[[ -z ${STUB_SUMMER_FAIL:-} ]] || exit 1
`
goStub := "#!" + bash + `
printf '%s\n' "$*" >>"$STUB_LOG/go.calls"
case "$1 ${2:-}" in
"env GOVERSION") printf '%s\n' "$STUB_GOVERSION"; exit 0 ;;
"env GOBIN") printf '%s\n' "$STUB_GOBIN"; exit 0 ;;
"env GOPATH") printf '%s\n' "$STUB_GOPATH"; exit 0 ;;
esac
if [[ $1 == -C && $3 == install && $4 == ./cmd/summer ]]; then
printf 'GOWORK=%s dir=%s\n' "${GOWORK-unset}" "$2" >>"$STUB_LOG/go.install"
bin="$STUB_GOBIN"
[[ -n $bin ]] || bin="${STUB_GOPATH%%:*}/bin"
printf '%s' "$SUMMER_STUB" >"$bin/summer"
'` + chmod + `' 0755 "$bin/summer"
exit 0
fi
printf 'unexpected go %s\n' "$*" >&2
exit 3
`
dockerStub := "#!" + bash + "\nprintf '%s\\n' \"$*\" >>\"$STUB_LOG/docker.calls\"\nexit 0\n"
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(h.tools, name), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
write("go", goStub)
write("docker", dockerStub)
h.env = append(h.env, "SUMMER_STUB="+summerStub)
h.remote = newFakeRemote(t, root, append(gitTestEnv(filepath.Join(root, "home")), "GIT_CEILING_DIRECTORIES="+root))
return h
}
// without removes a tool from the stub PATH.
func (h *scriptHarness) without(tool string) {
h.t.Helper()
if err := os.Remove(filepath.Join(h.tools, tool)); err != nil {
h.t.Fatal(err)
}
}
// run executes the script with args in h.work.
func (h *scriptHarness) run(args ...string) scriptResult {
h.t.Helper()
return h.runFile(h.script, h.work, args...)
}
// runIn executes the script with args in dir.
func (h *scriptHarness) runIn(dir string, args ...string) scriptResult {
h.t.Helper()
return h.runFile(h.script, dir, args...)
}
// runUpdate executes UpdateScript() with args in dir.
func (h *scriptHarness) runUpdate(dir string, args ...string) scriptResult {
h.t.Helper()
return h.runFile(h.updateFile, dir, args...)
}
// runFile executes file with args in dir; cmd.Dir and PWD both name dir.
func (h *scriptHarness) runFile(file, dir string, args ...string) scriptResult {
h.t.Helper()
path := h.tools
if h.gobinOnPath {
path += ":" + h.gobin
}
gobin := ""
if h.useGOBIN {
gobin = h.gobin
}
env := append(gitTestEnv(filepath.Join(h.root, "home")),
"PATH="+path,
"PWD="+dir,
"GIT_CEILING_DIRECTORIES="+h.root,
"SUMMERCMS_REPO="+h.remote.bare,
"STUB_LOG="+h.logs,
"STUB_GOVERSION="+h.goVersion,
"STUB_GOBIN="+gobin,
"STUB_GOPATH="+h.gopath,
// bash fills an unset SHELL from the passwd entry, so this default
// keeps cases independent of the developer's login shell; a case
// sets its own SHELL through h.env, which comes later and wins.
"SHELL=",
)
// The script must not depend on the harness identity: drop it.
filtered := env[:0]
for _, kv := range env {
if strings.HasPrefix(kv, "GIT_AUTHOR_") || strings.HasPrefix(kv, "GIT_COMMITTER_") {
continue
}
filtered = append(filtered, kv)
}
env = append(filtered, h.env...)
cmd := exec.Command(h.bash, append([]string{file}, args...)...)
if h.unsetShell {
// Unset SHELL inside bash, after startup filled it, then source the
// script ($0) with the case arguments so it runs with SHELL unset.
cmd = exec.Command(h.bash, append([]string{"-c", `unset SHELL; . "$0" "$@"`, file}, args...)...)
}
cmd.Dir = dir
cmd.Env = env
cmd.Stdin = strings.NewReader(stdinProbe)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
res := scriptResult{stdout: stdout.String(), stderr: stderr.String()}
var exitErr *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exitErr):
res.code = exitErr.ExitCode()
default:
h.t.Fatalf("run script: %v", err)
}
return res
}
// mustRun runs the script and requires success.
func (h *scriptHarness) mustRun(args ...string) scriptResult {
h.t.Helper()
res := h.run(args...)
if res.code != 0 {
h.t.Fatalf("script exit %d\nstdout:\n%s\nstderr:\n%s", res.code, res.stdout, res.stderr)
}
return res
}
func (h *scriptHarness) checkout() string { return filepath.Join(h.work, "summercms") }
func (h *scriptHarness) head() string {
h.t.Helper()
return h.remote.git(h.checkout(), "rev-parse", "HEAD")
}
func (h *scriptHarness) log(name string) string {
data, err := os.ReadFile(filepath.Join(h.logs, name))
if err != nil {
return ""
}
return string(data)
}
func (h *scriptHarness) exists(rel string) bool {
_, err := os.Lstat(filepath.Join(h.work, rel))
return err == nil
}
func expectCode(t *testing.T, res scriptResult, code int) {
t.Helper()
if res.code != code {
t.Fatalf("exit %d, want %d\nstdout:\n%s\nstderr:\n%s", res.code, code, res.stdout, res.stderr)
}
}
func expectContains(t *testing.T, what, got string, wants ...string) {
t.Helper()
for _, want := range wants {
if !strings.Contains(got, want) {
t.Fatalf("%s lacks %q:\n%s", what, want, got)
}
}
}
func expectLacks(t *testing.T, what, got string, unwanted ...string) {
t.Helper()
for _, u := range unwanted {
if strings.Contains(got, u) {
t.Fatalf("%s contains %q:\n%s", what, u, got)
}
}
}
// hintDir returns dir escaped the way new.sh escapes it inside the double
// quotes of the printed PATH hint: backslash, double quote and dollar for
// every shell, plus backtick and a single-quoted ! for POSIX shells.
func hintDir(dir string, fish bool) string {
if fish {
return strings.NewReplacer(`\`, `\\`, `"`, `\"`, `$`, `\$`).Replace(dir)
}
return strings.NewReplacer(`\`, `\\`, `"`, `\"`, `$`, `\$`, "`", "\\`", `!`, `"'!'"`).Replace(dir)
}
// hint is the PATH hint new.sh prints at the end of stdout.
func hint(gobin, note, line string) string {
return "\n==> summer is installed in " + gobin + ", which is not on your PATH. Run this (" + note + "):\n" + line + "\n"
}
// exportHint and fishHint are the two hint forms for gobin.
func exportHint(gobin, note string) string {
return hint(gobin, note, ` export PATH="`+hintDir(gobin, false)+`:$PATH"`)
}
func fishHint(gobin string) string {
return hint(gobin, "fish_add_path also keeps it for new fish sessions", ` fish_add_path "`+hintDir(gobin, true)+`"`)
}
// expectHintSuffix asserts stdout ends with the hint.
func expectHintSuffix(t *testing.T, stdout, want string) {
t.Helper()
if !strings.HasSuffix(stdout, want) {
t.Fatalf("stdout does not end with the hint\ngot:\n%q\nwant suffix:\n%q", stdout, want)
}
}
// metaGobin creates and selects a Go bin directory whose name holds a
// space, backslash, double quote, dollar, backtick and !.
func metaGobin(t *testing.T, h *scriptHarness) {
t.Helper()
h.gobin = filepath.Join(h.root, "go bin\\x\"y$HOME`id`!z")
if err := os.MkdirAll(h.gobin, 0o755); err != nil {
t.Fatal(err)
}
}
// expectNothingStarted asserts no clone and no go or summer call happened.
func expectNothingStarted(t *testing.T, h *scriptHarness) {
t.Helper()
if h.exists("summercms") {
t.Fatal("the framework was cloned")
}
if c := h.log("go.calls"); c != "" {
t.Fatalf("go was called:\n%s", c)
}
if c := h.log("summer.call"); c != "" {
t.Fatalf("summer was called:\n%s", c)
}
}
// expectRefusedUnchanged asserts a refusal with exit 1 that left HEAD at
// before and never reached go install or summer.
func expectRefusedUnchanged(t *testing.T, h *scriptHarness, res scriptResult, before string, msg string) {
t.Helper()
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, msg)
if got := h.head(); got != before {
t.Fatalf("HEAD moved from %s to %s", before, got)
}
if c := h.log("summer.call"); c != "" {
t.Fatalf("summer was called after a refusal:\n%s", c)
}
}
// resetLogs clears stub records between runs of one case.
func (h *scriptHarness) resetLogs() {
h.t.Helper()
for _, n := range []string{"go.calls", "go.install", "summer.call", "summer.stdin", "docker.calls", "openssl.calls"} {
_ = os.Remove(filepath.Join(h.logs, n))
}
}
// replaceLine is a single-line go.mod replace of the framework.
func replaceLine(target string) string {
return "replace " + fwModule + " => " + target
}
// appAt writes an application fixture into dir: go.mod and summer.yaml.
func (h *scriptHarness) appAt(dir, gomod string) string {
h.t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
h.t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o644); err != nil {
h.t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "summer.yaml"), []byte("name: x\n"), 0o644); err != nil {
h.t.Fatal(err)
}
return dir
}
// app writes an application fixture at <work>/sm-<name>-app.
func (h *scriptHarness) app(name, gomod string) string {
h.t.Helper()
return h.appAt(filepath.Join(h.work, "sm-"+name+"-app"), gomod)
}
// appGoMod is a go.mod whose framework replace points at target.
func appGoMod(name, target string) string {
return "module example.com/" + name + "\n\n" + replaceLine(target) + "\n"
}
// updateReady clones the framework with a create run, writes sm-blog-app
// pointing at it and clears the stub records; it returns the app directory.
func (h *scriptHarness) updateReady() string {
h.t.Helper()
h.mustRun("demo")
app := h.app("blog", appGoMod("blog", "../summercms"))
h.resetLogs()
return app
}
// mustUpdate runs --update in dir and requires success.
func (h *scriptHarness) mustUpdate(dir string) scriptResult {
h.t.Helper()
res := h.runIn(dir, "--update")
if res.code != 0 {
h.t.Fatalf("update exit %d\nstdout:\n%s\nstderr:\n%s", res.code, res.stdout, res.stderr)
}
return res
}
// stubOpenssl puts an openssl stub on PATH that records its arguments and
// prints output.
func (h *scriptHarness) stubOpenssl(output string) {
h.t.Helper()
body := "#!" + h.bash + "\nprintf '%s\\n' \"$*\" >>\"$STUB_LOG/openssl.calls\"\nprintf '%s\\n' '" + output + "'\n"
if err := os.WriteFile(filepath.Join(h.tools, "openssl"), []byte(body), 0o755); err != nil {
h.t.Fatal(err)
}
}
// expectBuilt asserts summer build ran once, in app, with no stdin.
func expectBuilt(t *testing.T, h *scriptHarness, app string) {
t.Helper()
if got, want := h.log("summer.call"), "cwd="+app+"\narg=build\n"; got != want {
t.Fatalf("summer call = %q, want %q", got, want)
}
if got := h.log("summer.stdin"); got != "" {
t.Fatalf("summer read stdin %q", got)
}
}
// expectNoInstallOrBuild asserts neither go install nor summer ran.
func expectNoInstallOrBuild(t *testing.T, h *scriptHarness) {
t.Helper()
if c := h.log("go.install"); c != "" {
t.Fatalf("go install ran:\n%s", c)
}
if c := h.log("summer.call"); c != "" {
t.Fatalf("summer was called:\n%s", c)
}
}
// branch prints the branch checked out in the framework checkout.
func (h *scriptHarness) branch() string {
h.t.Helper()
return h.remote.git(h.checkout(), "symbolic-ref", "--short", "HEAD")
}
// writeEnv writes a .env file with exactly mode, whatever the umask.
func writeEnv(t *testing.T, path, data string, mode os.FileMode) {
t.Helper()
if err := os.WriteFile(path, []byte(data), mode); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, mode); err != nil {
t.Fatal(err)
}
}
// readFile returns the bytes of path as a string.
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
// secretRe is the shape of a generated admin token secret.
var secretRe = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`)
const adminKey = "SUMMER_ADMIN__JWT__SECRET"
// expectAppended asserts the .env at path is before plus one generated
// admin secret line, that the secret is well-formed and that neither output
// stream holds it. It returns the secret. Failure messages name only the key
// and the value length, never .env bytes.
func expectAppended(t *testing.T, res scriptResult, path, before string) string {
t.Helper()
got := readFile(t, path)
prefix := before + adminKey + "="
if !strings.HasPrefix(got, prefix) || !strings.HasSuffix(got, "\n") {
t.Fatalf(".env is not the original bytes plus one %s line (length %d, original %d)", adminKey, len(got), len(before))
}
v := strings.TrimSuffix(strings.TrimPrefix(got, prefix), "\n")
if !secretRe.MatchString(v) {
t.Fatalf("%s value of length %d is not 44-character base64", adminKey, len(v))
}
if strings.Contains(res.stdout, v) || strings.Contains(res.stderr, v) {
t.Fatalf("the output contains the generated %s value", adminKey)
}
return v
}
// expectMode asserts the permission bits of path.
func expectMode(t *testing.T, path string, mode os.FileMode) {
t.Helper()
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := fi.Mode().Perm(); got != mode {
t.Fatalf("mode of %s = %v, want %v", path, got, mode)
}
}
func TestScriptBehavior(t *testing.T) {
cases := []struct {
name string
run func(t *testing.T, h *scriptHarness)
}{
{"help prints usage and exits 0", func(t *testing.T, h *scriptHarness) {
res := h.run("--help")
expectCode(t, res, 0)
expectContains(t, "stdout", res.stdout, "Usage: curl -fsSL https://summercms.io/new | bash -s -- <name>", "SUMMERCMS_REF")
expectNothingStarted(t, h)
}},
{"no argument is a usage error", func(t *testing.T, h *scriptHarness) {
res := h.run()
expectCode(t, res, 2)
expectContains(t, "stderr", res.stderr, "Usage:")
expectNothingStarted(t, h)
}},
{"two arguments are a usage error", func(t *testing.T, h *scriptHarness) {
res := h.run("demo", "extra")
expectCode(t, res, 2)
expectNothingStarted(t, h)
}},
{"upper-case name is rejected", func(t *testing.T, h *scriptHarness) {
res := h.run("Demo")
expectCode(t, res, 2)
expectContains(t, "stderr", res.stderr, "invalid application name")
expectNothingStarted(t, h)
}},
{"name with shell metacharacters is rejected", func(t *testing.T, h *scriptHarness) {
res := h.run("demo;touch pwned")
expectCode(t, res, 2)
if h.exists("pwned") {
t.Fatal("the name was executed")
}
expectNothingStarted(t, h)
}},
{"existing app stops before any clone", func(t *testing.T, h *scriptHarness) {
if err := os.Mkdir(filepath.Join(h.work, "sm-demo-app"), 0o755); err != nil {
t.Fatal(err)
}
res := h.run("demo")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "sm-demo-app already exists")
expectNothingStarted(t, h)
}},
{"Go missing names go.dev/dl", func(t *testing.T, h *scriptHarness) {
h.without("go")
res := h.run("demo")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "Go is not installed", "https://go.dev/dl/")
expectNothingStarted(t, h)
}},
{"Go older than 1.27 is refused", func(t *testing.T, h *scriptHarness) {
h.goVersion = "go1.26.3"
res := h.run("demo")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "found go1.26.3", "https://go.dev/dl/")
if h.exists("summercms") {
t.Fatal("cloned despite an old Go")
}
}},
{"devel Go version only warns", func(t *testing.T, h *scriptHarness) {
h.goVersion = "devel go1.28-0123456789ab"
res := h.mustRun("demo")
expectContains(t, "stderr", res.stderr, "warning: could not read the Go version")
if h.log("summer.call") == "" {
t.Fatal("summer was not called")
}
}},
{"git missing is an error", func(t *testing.T, h *scriptHarness) {
h.without("git")
res := h.run("demo")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "git is not installed")
expectNothingStarted(t, h)
}},
{"Docker missing only warns", func(t *testing.T, h *scriptHarness) {
h.without("docker")
res := h.mustRun("demo")
expectContains(t, "stderr", res.stderr, "warning: Docker is not installed")
if h.log("summer.call") == "" {
t.Fatal("summer was not called")
}
}},
{"fresh clone at the newest release", func(t *testing.T, h *scriptHarness) {
res := h.mustRun("demo")
if got, want := h.head(), h.remote.tags["v0.10.0"]; got != want {
t.Fatalf("HEAD = %s, want v0.10.0 %s (numeric order, pre-release ignored)", got, want)
}
cmd := exec.Command("git", "-C", h.checkout(), "symbolic-ref", "-q", "HEAD")
if err := cmd.Run(); err == nil {
t.Fatal("checkout is on a branch, want detached")
}
fw := filepath.Join(h.work, "summercms")
call := h.log("summer.call")
want := "cwd=" + h.work + "\narg=new\narg=demo\narg=--framework\narg=" + fw + "\n"
if call != want {
t.Fatalf("summer call:\n%s\nwant:\n%s", call, want)
}
if got := h.log("summer.stdin"); got != "" {
t.Fatalf("summer read stdin %q", got)
}
if got, want := h.log("go.install"), "GOWORK=off dir="+fw+"\n"; got != want {
t.Fatalf("go install = %q, want %q", got, want)
}
expectContains(t, "stdout", res.stdout, " cd "+filepath.Join(h.work, "sm-demo-app")+"\n")
expectContains(t, "stderr", res.stderr, "framework target: v0.10.0")
}},
{"SUMMERCMS_REF selects a tag", func(t *testing.T, h *scriptHarness) {
h.env = append(h.env, "SUMMERCMS_REF=v0.2.0")
res := h.mustRun("demo")
if got, want := h.head(), h.remote.tags["v0.2.0"]; got != want {
t.Fatalf("HEAD = %s, want v0.2.0 %s", got, want)
}
expectContains(t, "stderr", res.stderr, "framework target: v0.2.0")
}},
{"SUMMERCMS_REF starting with a dash is refused", func(t *testing.T, h *scriptHarness) {
h.env = append(h.env, "SUMMERCMS_REF=--upload-pack=x")
res := h.run("demo")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "SUMMERCMS_REF must be a tag or commit")
if h.log("summer.call") != "" {
t.Fatal("summer was called")
}
}},
{"re-run moves forward to a new release", func(t *testing.T, h *scriptHarness) {
h.mustRun("demo")
next := h.remote.tag("v0.11.0")
h.resetLogs()
res := h.mustRun("demo")
if got := h.head(); got != next {
t.Fatalf("HEAD = %s, want v0.11.0 %s", got, next)
}
expectContains(t, "stderr", res.stderr, "updating the framework checkout", "framework target: v0.11.0")
}},
{"re-run at the newest release changes nothing", func(t *testing.T, h *scriptHarness) {
h.mustRun("demo")
before := h.head()
h.resetLogs()
res := h.mustRun("demo")
if got := h.head(); got != before {
t.Fatalf("HEAD moved from %s to %s", before, got)
}
expectContains(t, "stderr", res.stderr, "already at the target")
}},
{"tracked change refuses and is kept", func(t *testing.T, h *scriptHarness) {
h.mustRun("demo")
before := h.head()
mod := filepath.Join(h.checkout(), "go.mod")
edited := "module " + fwModule + "\n\ngo 1.27.0\n// local edit\n"
if err := os.WriteFile(mod, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
h.resetLogs()
res := h.run("demo")
expectRefusedUnchanged(t, h, res, before, "uncommitted changes to tracked files")
if got, _ := os.ReadFile(mod); string(got) != edited {
t.Fatalf("go.mod changed: %q", got)
}
}},
{"branch checkout refuses", func(t *testing.T, h *scriptHarness) {
h.mustRun("demo")
h.remote.git(h.checkout(), "switch", "-q", "-c", "mine")
before := h.head()
h.resetLogs()
res := h.run("demo")
expectRefusedUnchanged(t, h, res, before, "is on branch mine")
if got := h.remote.git(h.checkout(), "symbolic-ref", "--short", "HEAD"); got != "mine" {
t.Fatalf("branch = %s", got)
}
}},
{"non-fast-forward refuses", func(t *testing.T, h *scriptHarness) {
h.mustRun("demo")
h.remote.git(h.checkout(), "commit", "-q", "--allow-empty", "-m", "local work")
before := h.head()
h.resetLogs()
res := h.run("demo")
expectRefusedUnchanged(t, h, res, before, "is not a fast-forward")
}},
{"moved upstream tag refuses", func(t *testing.T, h *scriptHarness) {
h.mustRun("demo")
before := h.head()
moved := h.remote.moveTag("v0.10.0")
h.resetLogs()
res := h.run("demo")
if res.code == 0 {
t.Fatalf("script followed a moved tag\nstderr:\n%s", res.stderr)
}
if got := h.head(); got != before {
t.Fatalf("HEAD moved from %s to %s", before, got)
}
if got := h.remote.git(h.checkout(), "rev-parse", "refs/tags/v0.10.0^{commit}"); got == moved || got != before {
t.Fatalf("local v0.10.0 = %s, want the original %s (upstream moved to %s)", got, before, moved)
}
if h.log("summer.call") != "" {
t.Fatal("summer was called")
}
}},
{"another module in the directory refuses", func(t *testing.T, h *scriptHarness) {
dir := h.checkout()
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/other\n"), 0o644); err != nil {
t.Fatal(err)
}
res := h.run("demo")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "is not a SummerCMS framework checkout", "example.com/other")
if h.log("summer.call") != "" || h.log("go.install") != "" {
t.Fatal("the script went on after refusing the directory")
}
}},
{"framework directory that is not a git repository refuses", func(t *testing.T, h *scriptHarness) {
dir := h.checkout()
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+fwModule+"\n"), 0o644); err != nil {
t.Fatal(err)
}
res := h.run("demo")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "is not a git repository")
if _, err := os.Stat(filepath.Join(dir, ".git")); !os.IsNotExist(err) {
t.Fatalf("the script created .git: %v", err)
}
}},
{"empty SHELL prints the generic hint", func(t *testing.T, h *scriptHarness) {
res := h.mustRun("demo")
expectHintSuffix(t, res.stdout, "\n==> summer is installed in "+h.gobin+", which is not on your PATH. Run this (and add it to your shell profile):\n export PATH=\""+h.gobin+":$PATH\"\n")
}},
{"unset SHELL prints the generic hint", func(t *testing.T, h *scriptHarness) {
h.unsetShell = true
res := h.mustRun("demo")
expectLacks(t, "stderr", res.stderr, "unbound variable")
expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to your shell profile"))
}},
{"unknown SHELL prints the generic hint", func(t *testing.T, h *scriptHarness) {
h.env = append(h.env, "SHELL=/bin/tcsh")
res := h.mustRun("demo")
expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to your shell profile"))
}},
{"fish SHELL prints fish_add_path", func(t *testing.T, h *scriptHarness) {
h.env = append(h.env, "SHELL=/usr/bin/fish")
res := h.mustRun("demo")
expectHintSuffix(t, res.stdout, fishHint(h.gobin))
expectContains(t, "stdout", res.stdout, "fish_add_path also keeps it for new fish sessions", ` fish_add_path "`+h.gobin+`"`)
expectLacks(t, "stdout", res.stdout, "export PATH", "shell profile")
}},
{"zsh SHELL names ~/.zshrc", func(t *testing.T, h *scriptHarness) {
h.env = append(h.env, "SHELL=/bin/zsh")
res := h.mustRun("demo")
expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to ~/.zshrc"))
expectLacks(t, "stdout", res.stdout, "fish_add_path")
}},
{"bash SHELL names ~/.bashrc", func(t *testing.T, h *scriptHarness) {
h.env = append(h.env, "SHELL=/usr/local/bin/bash")
res := h.mustRun("demo")
expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to ~/.bashrc"))
expectLacks(t, "stdout", res.stdout, "fish_add_path")
}},
{"metacharacters in the dir are escaped for zsh", func(t *testing.T, h *scriptHarness) {
metaGobin(t, h)
h.env = append(h.env, "SHELL=/bin/zsh")
res := h.mustRun("demo")
want := " export PATH=\"" + hintDir(h.root, false) + "/" + (`go bin\\x\"y\$HOME\` + "`" + `id\` + "`" + `"'!'"z`) + ":$PATH\"\n"
expectContains(t, "stdout", res.stdout, want)
expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to ~/.zshrc"))
}},
{"metacharacters in the dir are escaped for fish", func(t *testing.T, h *scriptHarness) {
metaGobin(t, h)
h.env = append(h.env, "SHELL=/usr/bin/fish")
res := h.mustRun("demo")
want := " fish_add_path \"" + hintDir(h.root, true) + "/" + (`go bin\\x\"y\$HOME` + "`id`!z") + "\"\n"
expectContains(t, "stdout", res.stdout, want)
expectHintSuffix(t, res.stdout, fishHint(h.gobin))
}},
{"GOBIN on PATH prints no hint", func(t *testing.T, h *scriptHarness) {
h.gobinOnPath = true
h.env = append(h.env, "SHELL=/usr/bin/fish")
res := h.mustRun("demo")
expectLacks(t, "stdout", res.stdout, "export PATH", "fish_add_path", "not on your PATH")
}},
{"empty GOBIN installs into GOPATH/bin", func(t *testing.T, h *scriptHarness) {
h.useGOBIN = false
res := h.mustRun("demo")
bin := filepath.Join(h.gopath, "bin")
if _, err := os.Stat(filepath.Join(bin, "summer")); err != nil {
t.Fatalf("summer not installed in GOPATH/bin: %v", err)
}
expectContains(t, "stderr", res.stderr, "installing summer into "+bin)
expectContains(t, "stdout", res.stdout, `export PATH="`+bin+`:$PATH"`)
}},
{"update: help names the update forms", func(t *testing.T, h *scriptHarness) {
res := h.run("--help")
expectCode(t, res, 0)
expectContains(t, "stdout", res.stdout, "Usage: curl -fsSL https://summercms.io/new | bash -s -- <name>", "SUMMERCMS_REF",
"https://summercms.io/update | bash", "bash -s -- --update")
// The rejected flag name, split so a grep of the sources finds none.
expectLacks(t, "stdout", res.stdout, "--"+"upgrade")
expectNothingStarted(t, h)
}},
{"update: --update with a name is a usage error", func(t *testing.T, h *scriptHarness) {
for _, args := range [][]string{{"--update", "demo"}, {"demo", "--update"}} {
res := h.run(args...)
expectCode(t, res, 2)
expectContains(t, "stderr", res.stderr, "--update takes no other argument", "Usage:")
expectNothingStarted(t, h)
}
}},
{"update: a repeated --update is accepted", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
res := h.runIn(app, "--update", "--update")
expectCode(t, res, 0)
expectBuilt(t, h, app)
}},
{"update: outside an application refuses", func(t *testing.T, h *scriptHarness) {
res := h.run("--update")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "run this from inside an sm-<name>-app directory")
expectNothingStarted(t, h)
}},
{"update: an app without summer.yaml refuses", func(t *testing.T, h *scriptHarness) {
app := filepath.Join(h.work, "sm-blog-app")
if err := os.MkdirAll(app, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(app, "go.mod"), []byte(appGoMod("blog", "../summercms")), 0o644); err != nil {
t.Fatal(err)
}
res := h.runIn(app, "--update")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "run this from inside an sm-<name>-app directory", "summer.yaml")
if c := h.log("go.calls"); c != "" {
t.Fatalf("go was called:\n%s", c)
}
}},
{"update: go.mod without a framework replace refuses", func(t *testing.T, h *scriptHarness) {
app := h.app("blog", "module example.com/blog\n\nrequire "+fwModule+" v0.1.13\n\nreplace example.com/x => ../x\n")
res := h.runIn(app, "--update")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "has no replace for "+fwModule)
if c := h.log("go.calls"); c != "" {
t.Fatalf("go was called:\n%s", c)
}
}},
{"update: a module-version replace refuses", func(t *testing.T, h *scriptHarness) {
app := h.app("blog", "module example.com/blog\n\n"+replaceLine(fwModule+" v0.1.13")+"\n")
res := h.runIn(app, "--update")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "not a local checkout", fwModule+" v0.1.13")
if c := h.log("go.calls"); c != "" {
t.Fatalf("go was called:\n%s", c)
}
}},
{"update: a missing replace target refuses", func(t *testing.T, h *scriptHarness) {
app := h.app("blog", appGoMod("blog", "../nowhere"))
res := h.runIn(app, "--update")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, filepath.Join(h.work, "nowhere")+", which does not exist")
if c := h.log("go.calls"); c != "" {
t.Fatalf("go was called:\n%s", c)
}
}},
{"update: a replace target that is another module refuses", func(t *testing.T, h *scriptHarness) {
other := filepath.Join(h.work, "other")
if err := os.Mkdir(other, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(other, "go.mod"), []byte("module example.com/other\n"), 0o644); err != nil {
t.Fatal(err)
}
app := h.app("blog", appGoMod("blog", "../other"))
res := h.runIn(app, "--update")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "is not a SummerCMS framework checkout", "example.com/other")
expectNoInstallOrBuild(t, h)
}},
{"update: a detached checkout moves to the new release and the app is rebuilt", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
next := h.remote.tag("v0.11.0")
res := h.mustUpdate(app)
if got := h.head(); got != next {
t.Fatalf("HEAD = %s, want v0.11.0 %s", got, next)
}
if err := exec.Command("git", "-C", h.checkout(), "symbolic-ref", "-q", "HEAD").Run(); err == nil {
t.Fatal("checkout is on a branch, want detached")
}
expectContains(t, "stderr", res.stderr, "updating the framework checkout", "framework target: v0.11.0")
if got, want := h.log("go.install"), "GOWORK=off dir="+h.checkout()+"\n"; got != want {
t.Fatalf("go install = %q, want %q", got, want)
}
expectBuilt(t, h, app)
expectHintSuffix(t, res.stdout, "\n==> Done. Run summer migrate, then restart summer serve.\n"+exportHint(h.gobin, "and add it to your shell profile"))
}},
{"update: at the newest release it still reinstalls and rebuilds", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
before := h.head()
res := h.mustUpdate(app)
if got := h.head(); got != before {
t.Fatalf("HEAD moved from %s to %s", before, got)
}
expectContains(t, "stderr", res.stderr, "already at the target")
if h.log("go.install") == "" {
t.Fatal("go install did not run")
}
expectBuilt(t, h, app)
}},
{"update: SUMMERCMS_REF selects the tag of a detached checkout", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
want := h.remote.tag("v0.11.0")
h.remote.tag("v0.12.0")
h.env = append(h.env, "SUMMERCMS_REF=v0.11.0")
h.mustUpdate(app)
if got := h.head(); got != want {
t.Fatalf("HEAD = %s, want v0.11.0 %s", got, want)
}
}},
{"update: a block replace with an absolute path", func(t *testing.T, h *scriptHarness) {
h.mustRun("demo")
app := h.app("blog", "module example.com/blog\n\nreplace (\n\texample.com/x => ./x\n\t"+fwModule+" v0.0.0 => "+h.checkout()+" // local\n)\n")
h.resetLogs()
next := h.remote.tag("v0.11.0")
h.mustUpdate(app)
if got := h.head(); got != next {
t.Fatalf("HEAD = %s, want v0.11.0 %s", got, next)
}
expectBuilt(t, h, app)
}},
{"update: a failed build exits 1 naming the app", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
h.env = append(h.env, "STUB_SUMMER_FAIL=1")
res := h.runIn(app, "--update")
expectCode(t, res, 1)
expectContains(t, "stderr", res.stderr, "summer build failed in "+app)
expectLacks(t, "stdout", res.stdout, "==> Done.")
}},
{"update: fish SHELL ends with fish_add_path", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
h.env = append(h.env, "SHELL=/usr/bin/fish")
res := h.mustUpdate(app)
expectHintSuffix(t, res.stdout, "\n==> Done. Run summer migrate, then restart summer serve.\n"+fishHint(h.gobin))
}},
{"update: GOBIN on PATH prints no hint", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
h.gobinOnPath = true
res := h.mustUpdate(app)
if res.stdout != "\n==> Done. Run summer migrate, then restart summer serve.\n" {
t.Fatalf("stdout = %q", res.stdout)
}
}},
{"update: create-mode stdout is unchanged", func(t *testing.T, h *scriptHarness) {
res := h.mustRun("demo")
want := "\n==> Done. Start with:\n cd " + filepath.Join(h.work, "sm-demo-app") + "\n" + exportHint(h.gobin, "and add it to your shell profile")
if res.stdout != want {
t.Fatalf("stdout = %q\nwant %q", res.stdout, want)
}
}},
{"update: UpdateScript runs update mode with no arguments", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
res := h.runUpdate(app)
expectCode(t, res, 0)
expectBuilt(t, h, app)
h.resetLogs()
res = h.runUpdate(app, "demo")
expectCode(t, res, 2)
expectContains(t, "stderr", res.stderr, "--update takes no other argument")
expectNoInstallOrBuild(t, h)
}},
{"update: a branch checkout is pulled fast-forward", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
h.remote.git(h.checkout(), "switch", "-q", "main")
next := h.remote.commit()
res := h.mustUpdate(app)
if got := h.head(); got != next {
t.Fatalf("HEAD = %s, want the new main %s", got, next)
}
if got := h.branch(); got != "main" {
t.Fatalf("branch = %s", got)
}
expectContains(t, "stderr", res.stderr, "pulling main from origin/main (fast-forward only)", "framework checkout moved from")
if h.log("go.install") == "" {
t.Fatal("go install did not run")
}
expectBuilt(t, h, app)
h.resetLogs()
res = h.mustUpdate(app)
expectContains(t, "stderr", res.stderr, "framework checkout is already up to date")
if got := h.head(); got != next {
t.Fatalf("HEAD moved to %s", got)
}
}},
{"update: a dirty branch refuses and is kept", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
h.remote.git(h.checkout(), "switch", "-q", "main")
version := filepath.Join(h.checkout(), "VERSION")
if err := os.WriteFile(version, []byte("local edit\n"), 0o644); err != nil {
t.Fatal(err)
}
h.remote.commit()
before := h.head()
res := h.runIn(app, "--update")
expectRefusedUnchanged(t, h, res, before, "uncommitted changes to tracked files")
if got := h.branch(); got != "main" {
t.Fatalf("branch = %s", got)
}
if got := readFile(t, version); got != "local edit\n" {
t.Fatalf("VERSION = %q", got)
}
expectNoInstallOrBuild(t, h)
}},
{"update: a branch without an upstream refuses", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
h.remote.git(h.checkout(), "switch", "-q", "-c", "mine")
before := h.head()
res := h.runIn(app, "--update")
expectRefusedUnchanged(t, h, res, before, "has no upstream")
if got := h.branch(); got != "mine" {
t.Fatalf("branch = %s", got)
}
expectNoInstallOrBuild(t, h)
}},
{"update: a diverged branch refuses", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
h.remote.git(h.checkout(), "switch", "-q", "main")
h.remote.git(h.checkout(), "commit", "-q", "--allow-empty", "-m", "local work")
h.remote.commit()
before := h.head()
// With an identity a plain pull could merge; only --ff-only refuses.
h.env = append(h.env, "GIT_AUTHOR_NAME=U", "GIT_AUTHOR_EMAIL=u@example.com", "GIT_COMMITTER_NAME=U", "GIT_COMMITTER_EMAIL=u@example.com")
res := h.runIn(app, "--update")
expectRefusedUnchanged(t, h, res, before, "not a fast-forward")
expectNoInstallOrBuild(t, h)
}},
{"update: SUMMERCMS_REF is ignored on a branch", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
h.remote.git(h.checkout(), "switch", "-q", "main")
next := h.remote.commit()
h.env = append(h.env, "SUMMERCMS_REF=v0.2.0")
res := h.mustUpdate(app)
expectContains(t, "stderr", res.stderr, "SUMMERCMS_REF is ignored for a branch checkout")
if got := h.head(); got != next {
t.Fatalf("HEAD = %s, want the new main %s (not v0.2.0)", got, next)
}
}},
{"update: SUMMERCMS_DIR is ignored", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
h.env = append(h.env, "SUMMERCMS_DIR=elsewhere")
res := h.mustUpdate(app)
expectContains(t, "stderr", res.stderr, "SUMMERCMS_DIR is ignored by --update")
if got, want := h.log("go.install"), "GOWORK=off dir="+h.checkout()+"\n"; got != want {
t.Fatalf("go install = %q, want %q", got, want)
}
}},
{"update: other apps on the same checkout are named, not rebuilt", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
alpha := h.app("alpha", appGoMod("alpha", "../summercms"))
beta := h.app("beta", "module example.com/beta\n\nreplace (\n\t"+fwModule+" => "+h.checkout()+"\n)\n")
other := h.app("other", appGoMod("other", "../elsewhere"))
for _, d := range []string{alpha, beta, other} {
writeEnv(t, filepath.Join(d, ".env"), "SUMMER_DATABASE__DSN=x\n", 0o600)
}
res := h.mustUpdate(app)
var lines []string
for _, l := range strings.Split(res.stderr, "\n") {
if strings.Contains(l, "not rebuilt") {
lines = append(lines, l)
}
}
if len(lines) != 1 {
t.Fatalf("want one not rebuilt line, got %d:\n%s", len(lines), res.stderr)
}
expectContains(t, "not rebuilt line", lines[0], alpha, beta)
expectLacks(t, "not rebuilt line", lines[0], "sm-other-app", "sm-blog-app")
expectBuilt(t, h, app)
for _, d := range []string{alpha, beta} {
if got := readFile(t, filepath.Join(d, ".env")); got != "SUMMER_DATABASE__DSN=x\n" {
t.Fatalf("%s/.env changed (length %d)", d, len(got))
}
}
}},
{"update: an app elsewhere still names apps next to the checkout", func(t *testing.T, h *scriptHarness) {
h.mustRun("demo")
alpha := h.app("alpha", appGoMod("alpha", "../summercms"))
far := h.appAt(filepath.Join(h.root, "apps", "sm-far-app"), appGoMod("far", h.checkout()))
h.resetLogs()
res := h.mustUpdate(far)
expectContains(t, "stderr", res.stderr, "not rebuilt", alpha)
expectBuilt(t, h, far)
}},
{"update: no other apps prints no not rebuilt line", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
res := h.mustUpdate(app)
expectLacks(t, "stderr", res.stderr, "not rebuilt")
}},
{"update: a missing admin secret is appended with head and base64", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
env := filepath.Join(app, ".env")
writeEnv(t, env, "SUMMER_DATABASE__DSN=x\n", 0o640)
res := h.mustUpdate(app)
expectAppended(t, res, env, "SUMMER_DATABASE__DSN=x\n")
expectMode(t, env, 0o640)
expectContains(t, "stderr", res.stderr, "added "+adminKey+" to "+env)
expectBuilt(t, h, app)
after := readFile(t, env)
h.resetLogs()
res = h.mustUpdate(app)
if readFile(t, env) != after {
t.Fatal(".env changed on the second run")
}
expectLacks(t, "stderr", res.stderr, "added "+adminKey)
}},
{"update: a .env without a final newline gets one first", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
env := filepath.Join(app, ".env")
writeEnv(t, env, "SUMMER_DATABASE__DSN=x", 0o600)
res := h.mustUpdate(app)
expectAppended(t, res, env, "SUMMER_DATABASE__DSN=x\n")
}},
{"update: an export-form admin secret counts as present", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
env := filepath.Join(app, ".env")
data := " export " + adminKey + " = abc\n"
writeEnv(t, env, data, 0o600)
res := h.mustUpdate(app)
if readFile(t, env) != data {
t.Fatal(".env changed")
}
expectLacks(t, "stderr", res.stderr, "added "+adminKey, "is empty")
}},
{"update: a commented admin secret does not count", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
env := filepath.Join(app, ".env")
data := "# " + adminKey + "=old\n"
writeEnv(t, env, data, 0o600)
res := h.mustUpdate(app)
expectAppended(t, res, env, data)
}},
{"update: an empty admin secret is kept with a warning", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
env := filepath.Join(app, ".env")
data := adminKey + "=\n"
writeEnv(t, env, data, 0o600)
res := h.mustUpdate(app)
if readFile(t, env) != data {
t.Fatal(".env changed")
}
expectContains(t, "stderr", res.stderr, adminKey+" in "+env+" is empty")
expectBuilt(t, h, app)
}},
{"update: openssl is preferred for the admin secret", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
const value = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY="
h.stubOpenssl(value)
env := filepath.Join(app, ".env")
writeEnv(t, env, "SUMMER_DATABASE__DSN=x\n", 0o600)
res := h.mustUpdate(app)
if got := h.log("openssl.calls"); got != "rand -base64 32\n" {
t.Fatalf("openssl calls = %q", got)
}
if got := expectAppended(t, res, env, "SUMMER_DATABASE__DSN=x\n"); got != value {
t.Fatal("the appended value is not the openssl output")
}
}},
{"update: a failed secret generator exits 1 and keeps .env", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
h.stubOpenssl("short")
h.without("head")
env := filepath.Join(app, ".env")
data := "SUMMER_DATABASE__DSN=x\n"
writeEnv(t, env, data, 0o600)
res := h.runIn(app, "--update")
expectCode(t, res, 1)
if readFile(t, env) != data {
t.Fatal(".env changed")
}
expectContains(t, "stderr", res.stderr, "add a "+adminKey+" line")
if c := h.log("summer.call"); c != "" {
t.Fatalf("summer was called:\n%s", c)
}
}},
{"update: no .env is skipped", func(t *testing.T, h *scriptHarness) {
app := h.updateReady()
res := h.mustUpdate(app)
if _, err := os.Lstat(filepath.Join(app, ".env")); !os.IsNotExist(err) {
t.Fatalf(".env was created: %v", err)
}
expectContains(t, "stderr", res.stderr, "no .env in "+app)
expectBuilt(t, h, app)
}},
}
if len(cases) < 67 {
t.Fatalf("only %d cases", len(cases))
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tc.run(t, newScriptHarness(t))
})
}
}
// TestScriptShellcheck runs shellcheck on new.sh; it is skipped only when
// shellcheck is not installed.
func TestScriptShellcheck(t *testing.T) {
sc, err := exec.LookPath("shellcheck")
if err != nil {
t.Skip("shellcheck not installed")
}
out, err := exec.Command(sc, "-s", "bash", "new.sh").CombinedOutput()
if err != nil {
t.Fatalf("shellcheck -s bash new.sh: %v\n%s", err, out)
}
}