Files

279 lines
9.1 KiB
Go

package publish
import (
"bytes"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/build"
"git.coffeylabs.org/coffey-labs/hotdog-cms/starter"
)
func starterSite(t *testing.T, dir string) {
t.Helper()
err := fs.WalkDir(starter.Files, "site", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
target := filepath.Join(dir, strings.TrimPrefix(p, "site"))
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, _ := starter.Files.ReadFile(p)
return os.WriteFile(target, []byte(strings.ReplaceAll(string(data), "{{SITE_NAME}}", "Test")), 0o644)
})
if err != nil {
t.Fatal(err)
}
}
func built(t *testing.T) string {
t.Helper()
site := t.TempDir()
starterSite(t, site)
res, err := build.Run(build.Options{SiteDir: site})
if err != nil {
t.Fatal(err)
}
return res.Out
}
func TestDirTarget(t *testing.T) {
out := built(t)
root := filepath.Join(t.TempDir(), "www")
var log bytes.Buffer
for i := 0; i < 2; i++ { // the second run replaces the first
if err := Run("local", Target{Type: "dir", Path: root}, out, &log); err != nil {
t.Fatal(err)
}
}
if _, err := os.Stat(filepath.Join(root, "index.html")); err != nil {
t.Fatal("index.html not published")
}
foreign := t.TempDir()
os.WriteFile(filepath.Join(foreign, "keep.txt"), []byte("x"), 0o644)
if err := Run("local", Target{Type: "dir", Path: foreign}, out, &log); err == nil {
t.Fatal("replaced a folder hotdog-cms did not write")
}
}
// fakeSSH puts an ssh on PATH that runs the remote command on this machine,
// so the rsync target can be exercised without a server.
func fakeSSH(t *testing.T) {
t.Helper()
bin := t.TempDir()
script := `#!/bin/sh
while [ $# -gt 0 ]; do
case "$1" in
-o|-p|-l) shift 2 ;;
-*) shift ;;
*) break ;;
esac
done
shift # the host
exec sh -c "$*"
`
if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func TestRsyncTarget(t *testing.T) {
if _, err := exec.LookPath("rsync"); err != nil {
t.Skip("rsync not installed")
}
fakeSSH(t)
out := built(t)
live := filepath.Join(t.TempDir(), "srv", "site")
tg := Target{Type: "rsync", Host: "[email protected]", Path: live}
var log bytes.Buffer
for i := 0; i < 2; i++ {
if err := Run("server", tg, out, &log); err != nil {
t.Fatalf("run %d: %v\n%s", i, err, log.String())
}
}
if _, err := os.Stat(filepath.Join(live, "articles", "hello-world", "index.html")); err != nil {
t.Fatal("article not published")
}
for _, leftover := range []string{live + ".hotdog-cms-staging", live + ".hotdog-cms-old"} {
if _, err := os.Stat(leftover); err == nil {
t.Errorf("%s left behind", leftover)
}
}
foreign := filepath.Join(t.TempDir(), "srv", "other")
os.MkdirAll(foreign, 0o755)
os.WriteFile(filepath.Join(foreign, "keep.txt"), []byte("x"), 0o644)
if err := Run("server", Target{Type: "rsync", Host: "h", Path: foreign}, out, &log); err == nil {
t.Fatal("replaced a remote folder hotdog-cms did not write")
}
if _, err := os.Stat(filepath.Join(foreign, "keep.txt")); err != nil {
t.Fatal("the remote folder's file is gone")
}
}
func TestTargetValidation(t *testing.T) {
bad := []Target{
{Type: "rsync", Host: "h", Path: "/"},
{Type: "rsync", Host: "h", Path: "/var"},
{Type: "rsync", Host: "h", Path: "relative/path"},
{Type: "rsync", Host: "h; rm -rf /", Path: "/srv/site"},
{Type: "container"},
{Type: "ftp", Path: "/x"},
}
for _, tg := range bad {
if err := tg.validate(); err == nil {
t.Errorf("accepted %+v", tg)
}
}
}
func TestCommandAndContainerContext(t *testing.T) {
out := built(t)
var log bytes.Buffer
if err := Run("hook", Target{Type: "command", Command: []string{"sh", "-c", `test -f "$HOTDOG_OUT/index.html" && test -f "$0/index.html"`, "{out}"}}, out, &log); err != nil {
t.Fatalf("command target: %v", err)
}
ctx := filepath.Join(t.TempDir(), "ctx")
if err := Run("image", Target{Type: "container", Context: ctx}, out, &log); err != nil {
t.Fatal(err)
}
for _, f := range []string{"Containerfile", "Dockerfile", "nginx.conf", "site/index.html"} {
if _, err := os.Stat(filepath.Join(ctx, f)); err != nil {
t.Errorf("context is missing %s", f)
}
}
}
func TestPull(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
repo := t.TempDir()
starterSite(t, repo)
git := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo, "-c", "user.name=t", "-c", "[email protected]", "-c", "commit.gpgsign=false"}, args...)...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
git("init", "-q", "-b", "main")
git("add", "-A")
git("commit", "-q", "-m", "site")
www := filepath.Join(t.TempDir(), "www")
opt := PullOptions{Repo: repo, Out: www, Cache: filepath.Join(t.TempDir(), "clone"), Log: &bytes.Buffer{}}
if err := Pull(opt); err != nil {
t.Fatal(err)
}
about := filepath.Join(www, "about", "index.html")
if b, _ := os.ReadFile(about); !strings.Contains(string(b), "<h1>About</h1>") {
t.Fatal("first pull did not publish")
}
os.WriteFile(filepath.Join(repo, "content", "about.md"), []byte("---\ntitle: Changed\n---\nNew text.\n"), 0o644)
git("commit", "-q", "-am", "edit")
if err := Pull(opt); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(about); !strings.Contains(string(b), "Changed") {
t.Fatal("second pull did not publish the change")
}
// A commit that breaks the build leaves the live site as it was.
os.WriteFile(filepath.Join(repo, "content", "broken.md"), []byte("---\nlayout: nope\ntitle: x\n---\n"), 0o644)
git("add", "-A")
git("commit", "-q", "-m", "broken")
if err := Pull(opt); err == nil {
t.Fatal("a broken build reported success")
}
if b, _ := os.ReadFile(about); !strings.Contains(string(b), "Changed") {
t.Fatal("a broken build changed the live site")
}
}
func TestPullRefusesFailedChecks(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
repo := t.TempDir()
starterSite(t, repo)
cfg, _ := os.ReadFile(filepath.Join(repo, "site.yaml"))
os.WriteFile(filepath.Join(repo, "site.yaml"), []byte(strings.Replace(string(cfg), " forbid: []", " forbid: ['internal\\.example']", 1)), 0o644)
git := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo, "-c", "user.name=t", "-c", "[email protected]", "-c", "commit.gpgsign=false"}, args...)...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
git("init", "-q", "-b", "main")
git("add", "-A")
git("commit", "-q", "-m", "site")
www := filepath.Join(t.TempDir(), "www")
opt := PullOptions{Repo: repo, Out: www, Cache: filepath.Join(t.TempDir(), "clone"), Log: &bytes.Buffer{}}
if err := Pull(opt); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(repo, "content", "about.md"), []byte("---\ntitle: About\n---\nOur server is db1.internal.example.\n"), 0o644)
git("commit", "-q", "-am", "leak a host name")
err := Pull(opt)
if err == nil || !strings.Contains(err.Error(), "check error") {
t.Fatalf("a commit failing its checks was published: %v", err)
}
if b, _ := os.ReadFile(filepath.Join(www, "about", "index.html")); strings.Contains(string(b), "internal.example") {
t.Fatal("the leak reached the live site")
}
if _, err := os.Stat(www + ".hotdog-cms-check"); err == nil {
t.Error("staging folder left behind")
}
}
func TestContainerCSP(t *testing.T) {
out := built(t)
ctx := filepath.Join(t.TempDir(), "ctx")
if err := Run("image", Target{Type: "container", Context: ctx, CSP: true}, out, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
conf, _ := os.ReadFile(filepath.Join(ctx, "nginx.conf"))
if !strings.Contains(string(conf), `add_header Content-Security-Policy "default-src 'self'; script-src 'self';`) {
t.Errorf("nginx.conf has no strict CSP:\n%s", conf)
}
}
func TestBeforeAndAfterSteps(t *testing.T) {
out := built(t)
root := filepath.Join(t.TempDir(), "www")
seen := filepath.Join(t.TempDir(), "after")
tg := Target{
Type: "dir",
Path: root,
Before: [][]string{{"sh", "-c", `echo indexed > "$0/search.txt"`, "{out}"}},
After: [][]string{{"sh", "-c", `test -f "$0/search.txt" && echo "$HOTDOG_TARGET" > "$1"`, root, seen}},
}
if err := tg.validate(); err != nil {
t.Fatal(err)
}
var log bytes.Buffer
if err := Run("live", tg, out, &log); err != nil {
t.Fatalf("%v\n%s", err, log.String())
}
if got, _ := os.ReadFile(seen); string(got) != "live\n" {
t.Errorf("after step didn't see the published change: %q", got)
}
// A failing before step stops the publish.
root2 := filepath.Join(t.TempDir(), "www")
tg = Target{Type: "dir", Path: root2, Before: [][]string{{"false"}}}
if err := Run("live", tg, out, &log); err == nil {
t.Error("published after a failed before step")
}
if _, err := os.Stat(root2); err == nil {
t.Error("before step failed but the site was published")
}
if err := (Target{Type: "dir", Path: "/x", After: [][]string{{}}}).validate(); err == nil {
t.Error("accepted an empty step")
}
}