package sunrise import ( "bytes" "context" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strconv" "strings" "testing" "time" ) func TestScriptEmbedded(t *testing.T) { want, err := os.ReadFile("new.sh") if err != nil { t.Fatal(err) } got := Script() if !bytes.Equal(got, want) { t.Fatal("Script() differs from new.sh") } for i := range got { got[i] = 'x' } if !bytes.Equal(Script(), want) { t.Fatal("modifying a returned slice changed the next Script() call") } } func TestScriptShape(t *testing.T) { s := string(Script()) lines := strings.Split(strings.TrimRight(s, "\n"), "\n") if lines[0] != "#!/usr/bin/env bash" { t.Fatalf("first line = %q, want the bash shebang", lines[0]) } if last := lines[len(lines)-1]; last != `main "$@"` { t.Fatalf("last non-empty line = %q, want %q", last, `main "$@"`) } if strings.Count(s, "\nmain \"$@\"") != 1 { t.Fatal("main must be called exactly once, on the last line") } bash := lookBash(t) if out, err := exec.Command(bash, "-n", "new.sh").CombinedOutput(); err != nil { t.Fatalf("bash -n new.sh: %v\n%s", err, out) } } func TestUpdateScript(t *testing.T) { s := Script() want := append(bytes.Clone(bytes.TrimSuffix(s, []byte("main \"$@\"\n"))), "main \"$@\" --update\n"...) if bytes.Equal(want, s) { t.Fatal("Script() does not end with main \"$@\"") } got := UpdateScript() if !bytes.Equal(got, want) { t.Fatal("UpdateScript() is not Script() with the last line replaced by main \"$@\" --update") } lines := strings.Split(strings.TrimRight(string(got), "\n"), "\n") if last := lines[len(lines)-1]; last != `main "$@" --update` { t.Fatalf("last line = %q", last) } for i := range got { got[i] = 'x' } if !bytes.Equal(UpdateScript(), want) { t.Fatal("modifying a returned slice changed the next UpdateScript() call") } bash := lookBash(t) file := filepath.Join(t.TempDir(), "update.sh") if err := os.WriteFile(file, want, 0o644); err != nil { t.Fatal(err) } if out, err := exec.Command(bash, "-n", file).CombinedOutput(); err != nil { t.Fatalf("bash -n on UpdateScript(): %v\n%s", err, out) } } func TestDeriveUpdateScriptPanicsWithoutMainCall(t *testing.T) { defer func() { if recover() == nil { t.Fatal("deriveUpdateScript accepted a script without the final main call") } }() deriveUpdateScript([]byte("#!/usr/bin/env bash\necho hi\n")) } // TestScriptTruncationRunsNothing runs every prefix of a script that ends // on a line boundary and every byte prefix of its final line, as a cut-short // download would arrive, and asserts none of them invokes a command or // changes a file. It covers Script() creating an application, Script() with // --update inside an application and UpdateScript() with no arguments. A // positive control per variant proves the stubs do record calls. func TestScriptTruncationRunsNothing(t *testing.T) { bash := lookBash(t) stubs := t.TempDir() marker := filepath.Join(t.TempDir(), "invoked") for _, name := range []string{"git", "go", "docker", "curl", "summer", "openssl", "head", "base64"} { body := "#!/bin/sh\necho \"" + name + " $*\" >>'" + marker + "'\n" if err := os.WriteFile(filepath.Join(stubs, name), []byte(body), 0o755); err != nil { t.Fatal(err) } } home := t.TempDir() scripts := t.TempDir() // emptyDir runs in an empty directory; appTree runs inside an // application whose go.mod points at a framework directory next to it. emptyDir := func(t *testing.T) (root, cwd string) { d := t.TempDir() return d, d } appTree := func(t *testing.T) (root, cwd string) { root = t.TempDir() app := filepath.Join(root, "sm-demo-app") fw := filepath.Join(root, "summercms") for _, d := range []string{app, fw} { if err := os.Mkdir(d, 0o755); err != nil { t.Fatal(err) } } files := map[string]string{ filepath.Join(app, "go.mod"): "module example.com/demo\n\n" + replaceLine("../summercms") + "\n", filepath.Join(app, "summer.yaml"): "name: demo\n", filepath.Join(fw, "go.mod"): "module " + fwModule + "\n", } for p, data := range files { if err := os.WriteFile(p, []byte(data), 0o644); err != nil { t.Fatal(err) } } return root, app } listing := func(t *testing.T, root string) string { t.Helper() var b strings.Builder err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { if err != nil { return err } info, err := d.Info() if err != nil { return err } rel, _ := filepath.Rel(root, p) b.WriteString(rel + " " + info.Mode().String() + " " + strconv.FormatInt(info.Size(), 10) + "\n") return nil }) if err != nil { t.Fatal(err) } return b.String() } variants := []struct { name string script []byte args []string tree func(t *testing.T) (root, cwd string) }{ {"Script demo", Script(), []string{"demo"}, emptyDir}, {"Script --update", Script(), []string{"--update"}, appTree}, {"UpdateScript", UpdateScript(), nil, appTree}, } for _, v := range variants { t.Run(v.name, func(t *testing.T) { _ = os.Remove(marker) run := func(t *testing.T, prefix []byte) { t.Helper() file := filepath.Join(scripts, "script.sh") if err := os.WriteFile(file, prefix, 0o644); err != nil { t.Fatal(err) } root, cwd := v.tree(t) before := listing(t, root) cmd := exec.Command(bash, append([]string{file}, v.args...)...) cmd.Dir = cwd cmd.Env = []string{"PATH=" + stubs, "HOME=" + home, "PWD=" + cwd, "LC_ALL=C"} _ = cmd.Run() if after := listing(t, root); after != before { t.Fatalf("prefix of %d bytes (ending %q) changed the tree:\nbefore:\n%s\nafter:\n%s", len(prefix), tail(prefix), before, after) } } s := v.script body := bytes.TrimRight(s, "\n") lastStart := bytes.LastIndexByte(body, '\n') + 1 var prefixes []int for i, c := range s[:lastStart] { if c == '\n' { prefixes = append(prefixes, i+1) } } prefixes = append([]int{0}, prefixes...) for k := 1; k < len(body)-lastStart; k++ { prefixes = append(prefixes, lastStart+k) } for _, n := range prefixes { run(t, s[:n]) if out, err := os.ReadFile(marker); err == nil { t.Fatalf("prefix of %d bytes (ending %q) invoked: %s", n, tail(s[:n]), out) } } if len(prefixes) < 50 { t.Fatalf("only %d prefixes tested", len(prefixes)) } run(t, s) if _, err := os.Stat(marker); err != nil { t.Fatalf("the complete script did not reach a stub, so the harness proves nothing: %v", err) } }) } } func tail(b []byte) string { if len(b) > 30 { b = b[len(b)-30:] } return string(b) } func lookBash(t *testing.T) string { t.Helper() bash, err := exec.LookPath("bash") if err != nil { t.Skip("bash not installed") } return bash } // TestBootstrapEndToEnd serves Script() over HTTP, pipes it into bash with // curl the way the one-liner does, clones this repository at HEAD, installs // summer into a GOBIN that is not on PATH and builds the generated app. It // then drops the admin token secret from the app's .env and runs // UpdateScript() from /update inside the app, which must restore the secret // without printing it and rebuild. It needs network access for Go modules // and runs only with SUMMERCMS_BOOTSTRAP_CHECK=1. func TestBootstrapEndToEnd(t *testing.T) { if os.Getenv("SUMMERCMS_BOOTSTRAP_CHECK") != "1" { t.Skip("set SUMMERCMS_BOOTSTRAP_CHECK=1 to run the bootstrap script end to end") } for _, tool := range []string{"bash", "curl", "git", "go"} { if _, err := exec.LookPath(tool); err != nil { t.Fatalf("%s is required: %v", tool, err) } } root, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { t.Fatal(err) } head := gitOut(t, root, "rev-parse", "HEAD") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") if r.URL.Path == "/update" { _, _ = w.Write(UpdateScript()) return } _, _ = w.Write(Script()) })) defer srv.Close() work := t.TempDir() gobin := t.TempDir() // The PATH hint form follows SHELL: pin bash so the export line is checked. env := []string{"SUMMERCMS_REPO=" + root, "SUMMERCMS_REF=" + head, "GOBIN=" + gobin, "SHELL=/bin/bash"} for _, kv := range os.Environ() { key, _, _ := strings.Cut(kv, "=") switch { case strings.HasPrefix(key, "SUMMER_"), strings.HasPrefix(key, "SUMMERCMS_"), key == "GOWORK", key == "GOBIN", key == "PWD", key == "GOFLAGS", key == "SHELL": continue } env = append(env, kv) } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, "bash", "-c", `set -o pipefail; curl -fsSL "$1" | bash -s -- demo`, "bash", srv.URL+"/new") cmd.Dir = work cmd.Env = append(env[:len(env):len(env)], "PWD="+work) out, err := cmd.CombinedOutput() t.Logf("bootstrap output:\n%s", out) if err != nil { t.Fatalf("curl | bash: %v", err) } checkout := filepath.Join(work, "summercms") if got := gitOut(t, checkout, "rev-parse", "HEAD"); got != head { t.Fatalf("checkout HEAD = %s, want %s", got, head) } if err := exec.Command("git", "-C", checkout, "symbolic-ref", "-q", "HEAD").Run(); err == nil { t.Fatal("checkout is on a branch, want a detached HEAD") } app := filepath.Join(work, "sm-demo-app") gomod, err := os.ReadFile(filepath.Join(app, "go.mod")) if err != nil { t.Fatal(err) } if !strings.Contains(string(gomod), "replace git.golem15.com/golem15/summercms => ../summercms\n") { t.Fatalf("go.mod lacks the relative framework replace:\n%s", gomod) } if !strings.Contains(string(out), " cd "+app+"\n") { t.Fatalf("output lacks the cd line for %s", app) } if !strings.Contains(string(out), `export PATH="`+gobin+`:$PATH"`) { t.Fatalf("output lacks the PATH hint for %s", gobin) } if !strings.Contains(string(out), "(and add it to ~/.bashrc):") { t.Fatal("output lacks the ~/.bashrc note of the bash PATH hint") } b := exec.CommandContext(ctx, filepath.Join(gobin, "summer"), "build") b.Dir = app b.Env = env if out, err := b.CombinedOutput(); err != nil { t.Fatalf("summer build in the generated app: %v\n%s", err, out) } // Update: drop the admin secret, as in an app created before the admin // was wired in, then run /update inside the app. envPath := filepath.Join(app, ".env") envData, err := os.ReadFile(envPath) if err != nil { t.Fatal(err) } var kept []string for _, line := range strings.SplitAfter(string(envData), "\n") { if !strings.HasPrefix(line, "SUMMER_ADMIN__JWT__SECRET=") { kept = append(kept, line) } } if err := os.WriteFile(envPath, []byte(strings.Join(kept, "")), 0o600); err != nil { t.Fatal(err) } if err := os.Remove(filepath.Join(app, "bin", "demo")); err != nil && !os.IsNotExist(err) { t.Fatal(err) } up := exec.CommandContext(ctx, "bash", "-c", `set -o pipefail; curl -fsSL "$1" | bash`, "bash", srv.URL+"/update") up.Dir = app up.Env = append(env[:len(env):len(env)], "PWD="+app) upOut, err := up.CombinedOutput() if err != nil { t.Fatalf("curl /update | bash: %v\n%s", err, upOut) } for _, want := range []string{"already at the target", "added SUMMER_ADMIN__JWT__SECRET to " + envPath, "Run summer migrate, then restart summer serve"} { if !strings.Contains(string(upOut), want) { t.Fatalf("update output lacks %q:\n%s", want, upOut) } } envData, err = os.ReadFile(envPath) if err != nil { t.Fatal(err) } var secrets []string for _, line := range strings.Split(string(envData), "\n") { if v, ok := strings.CutPrefix(line, "SUMMER_ADMIN__JWT__SECRET="); ok { secrets = append(secrets, v) } } if len(secrets) != 1 || len(secrets[0]) != 44 { t.Fatalf("want one SUMMER_ADMIN__JWT__SECRET line with a 44-character value, got %d lines", len(secrets)) } if strings.Contains(string(upOut), secrets[0]) { t.Fatal("the update output contains the generated secret") } if fi, err := os.Stat(envPath); err != nil || fi.Mode().Perm() != 0o600 { t.Fatalf(".env mode: %v %v", fi, err) } if _, err := os.Stat(filepath.Join(app, "bin", "demo")); err != nil { t.Fatalf("the update did not rebuild bin/demo: %v", err) } } func gitOut(t *testing.T, dir string, args ...string) string { t.Helper() out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output() if err != nil { t.Fatalf("git %v in %s: %v", args, dir, err) } return strings.TrimSpace(string(out)) }