- 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
387 lines
12 KiB
Go
387 lines
12 KiB
Go
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))
|
|
}
|