340 lines
10 KiB
Go
340 lines
10 KiB
Go
package template
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
|
)
|
|
|
|
func writeTemplateFile(t *testing.T, body string) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "t.tmpl")
|
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func modeDB() *models.Database {
|
|
db := models.InitDatabase("shop")
|
|
for _, sn := range []string{"a", "b"} {
|
|
s := models.InitSchema(sn)
|
|
for _, tn := range []string{"t1", "t2"} {
|
|
s.Tables = append(s.Tables, models.InitTable(tn, sn))
|
|
}
|
|
s.Scripts = append(s.Scripts, &models.Script{Name: "seed_" + sn})
|
|
db.Schemas = append(db.Schemas, s)
|
|
}
|
|
db.Domains = append(db.Domains, &models.Domain{Name: "billing"})
|
|
return db
|
|
}
|
|
|
|
func newTestWriter(t *testing.T, body, mode, pattern, out string) (*Writer, error) {
|
|
t.Helper()
|
|
meta := map[string]interface{}{"template_path": writeTemplateFile(t, body)}
|
|
if mode != "" {
|
|
meta["mode"] = mode
|
|
}
|
|
if pattern != "" {
|
|
meta["filename_pattern"] = pattern
|
|
}
|
|
return NewWriter(&writers.WriterOptions{OutputPath: out, Metadata: meta})
|
|
}
|
|
|
|
func TestNewWriterErrors(t *testing.T) {
|
|
if _, err := NewWriter(&writers.WriterOptions{}); err == nil {
|
|
t.Error("expected error for missing template path")
|
|
}
|
|
_, err := NewWriter(&writers.WriterOptions{Metadata: map[string]interface{}{"template_path": "/no/such/file"}})
|
|
var te *TemplateError
|
|
if !errors.As(err, &te) || te.Phase != "load" {
|
|
t.Errorf("load error: %v", err)
|
|
}
|
|
_, err = newTestWriter(t, "{{ .Unclosed ", "", "", "")
|
|
if !errors.As(err, &te) || te.Phase != "parse" {
|
|
t.Errorf("parse error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterModes(t *testing.T) {
|
|
tests := []struct {
|
|
name, mode, body, pattern string
|
|
wantFiles []string
|
|
}{
|
|
{"database", "database", "{{.Database.Name}}", "", []string{"out.txt"}},
|
|
{"schema", "schema", "{{.Schema.Name}}", "{{.Name}}.txt", []string{"a.txt", "b.txt"}},
|
|
{"table", "table", "{{.Table.Name}}", "{{.ParentSchema.Name}}_{{.Name}}.txt", []string{"a_t1.txt", "a_t2.txt", "b_t1.txt", "b_t2.txt"}},
|
|
{"script", "script", "{{.Script.Name}}", "{{.Name}}.sql", []string{"seed_a.sql", "seed_b.sql"}},
|
|
{"domain", "domain", "{{.Domain.Name}}", "{{.Name}}.md", []string{"billing.md"}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
outDir := t.TempDir()
|
|
out := outDir
|
|
if tt.mode == "database" {
|
|
out = filepath.Join(outDir, "out.txt")
|
|
}
|
|
w, err := newTestWriter(t, tt.body, tt.mode, tt.pattern, out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.WriteDatabase(modeDB()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, f := range tt.wantFiles {
|
|
if _, err := os.Stat(filepath.Join(outDir, f)); err != nil {
|
|
t.Errorf("missing %s: %v", f, err)
|
|
}
|
|
}
|
|
entries, _ := os.ReadDir(outDir)
|
|
if len(entries) != len(tt.wantFiles) {
|
|
t.Errorf("got %d files, want %d", len(entries), len(tt.wantFiles))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriterDatabaseModeContent(t *testing.T) {
|
|
out := filepath.Join(t.TempDir(), "sub", "dir", "o.txt")
|
|
w, err := newTestWriter(t, "{{.Database.Name}}:{{len .Database.Schemas}}", "", "", out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.WriteDatabase(modeDB()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := os.ReadFile(out)
|
|
if err != nil || string(data) != "shop:2" {
|
|
t.Errorf("content %q err %v", data, err)
|
|
}
|
|
}
|
|
|
|
func TestWriterUnknownMode(t *testing.T) {
|
|
w, err := newTestWriter(t, "x", "bogus", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.WriteDatabase(modeDB()); err == nil || !strings.Contains(err.Error(), "unknown entrypoint mode") {
|
|
t.Errorf("got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriterExecuteErrors(t *testing.T) {
|
|
// Execution failure: field does not exist on TemplateData.
|
|
for _, mode := range []string{"database", "schema", "table", "script", "domain"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
w, err := newTestWriter(t, "{{.NoSuchField}}", mode, "", t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = w.WriteDatabase(modeDB())
|
|
var te *TemplateError
|
|
if !errors.As(err, &te) || te.Phase != "execute" {
|
|
t.Errorf("got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriterBadFilenamePattern(t *testing.T) {
|
|
for _, pattern := range []string{"{{.Unclosed", "{{.NoSuchField}}"} {
|
|
for _, mode := range []string{"schema", "table", "script", "domain"} {
|
|
w, err := newTestWriter(t, "x", mode, pattern, t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.WriteDatabase(modeDB()); err == nil {
|
|
t.Errorf("mode %s pattern %q: expected error", mode, pattern)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWriterWriteOutputFailure(t *testing.T) {
|
|
// Output path whose parent is a regular file cannot be created.
|
|
blocker := filepath.Join(t.TempDir(), "file")
|
|
if err := os.WriteFile(blocker, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w, err := newTestWriter(t, "x", "database", "", filepath.Join(blocker, "child", "o.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.WriteDatabase(modeDB()); err == nil {
|
|
t.Error("expected write failure")
|
|
}
|
|
}
|
|
|
|
func TestWriterGenerateFilenameOutputPathForms(t *testing.T) {
|
|
dir := t.TempDir()
|
|
data := NewTableData(models.InitTable("users", "public"), nil, nil, nil)
|
|
|
|
tests := []struct {
|
|
name, out, want string
|
|
}{
|
|
{"no output path", "", "users.txt"},
|
|
{"existing dir", dir, filepath.Join(dir, "users.txt")},
|
|
{"trailing separator", filepath.Join(dir, "new") + string(filepath.Separator), filepath.Join(dir, "new", "users.txt")},
|
|
{"nonexistent path is root", filepath.Join(dir, "x.out"), filepath.Join(dir, "x.out", "users.txt")},
|
|
{"bare path is root", "x.out", filepath.Join("x.out", "users.txt")},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
w, err := newTestWriter(t, "x", "table", "{{.Name}}.txt", tt.out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := w.generateFilename(data)
|
|
if err != nil || got != tt.want {
|
|
t.Errorf("got %q err %v, want %q", got, err, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriterWriteSchemaAndTable(t *testing.T) {
|
|
out := filepath.Join(t.TempDir(), "o.txt")
|
|
w, err := newTestWriter(t, "{{range .Database.Schemas}}{{.Name}}:{{len .Tables}};{{end}}", "", "", out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db := modeDB()
|
|
if err := w.WriteSchema(db.Schemas[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if data, _ := os.ReadFile(out); string(data) != "a:2;" {
|
|
t.Errorf("WriteSchema: %q", data)
|
|
}
|
|
if err := w.WriteTable(db.Schemas[1].Tables[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if data, _ := os.ReadFile(out); string(data) != "b:1;" {
|
|
t.Errorf("WriteTable: %q", data)
|
|
}
|
|
}
|
|
|
|
func TestMultiFileRejectsUnsafeFilenamesBeforeWriting(t *testing.T) {
|
|
for _, name := range []string{"../escape", "/absolute", `..\escape`, `C:\escape`, `\\server\share\file`, "a/../escape", "a//escape", "CON", "nul.txt", "COM¹.txt", "file:stream", "trailing.", "trailing ", "line\nbreak"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
out := filepath.Join(t.TempDir(), "new-output")
|
|
w, err := newTestWriter(t, "content", "schema", "{{.Name}}", out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db := models.InitDatabase("unsafe")
|
|
db.Schemas = []*models.Schema{models.InitSchema("valid"), models.InitSchema(name)}
|
|
if err := w.WriteDatabase(db); err == nil {
|
|
t.Fatal("expected rejected filename")
|
|
}
|
|
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
|
t.Fatalf("validation failure must not create output: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMultiFileCollisionsDoNotOverwrite(t *testing.T) {
|
|
for _, names := range [][]string{{"same.txt", "same.txt"}, {"SAME.txt", "same.txt"}, {"file", "file/child.txt"}} {
|
|
out := t.TempDir()
|
|
seed := filepath.Join(out, "same.txt")
|
|
if err := os.WriteFile(seed, []byte("original"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w, err := newTestWriter(t, "replacement", "schema", "{{.Name}}", out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db := models.InitDatabase("collision")
|
|
for _, name := range names {
|
|
db.Schemas = append(db.Schemas, models.InitSchema(name))
|
|
}
|
|
if err := w.WriteDatabase(db); err == nil || !strings.Contains(err.Error(), "collision") {
|
|
t.Fatalf("expected collision error: %v", err)
|
|
}
|
|
got, err := os.ReadFile(seed)
|
|
if err != nil || string(got) != "original" {
|
|
t.Fatalf("existing output changed: %q %v", got, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMultiFileRejectsSymlinksBeforeAnyWrites(t *testing.T) {
|
|
for _, kind := range []string{"parent", "leaf", "root"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
out, external := t.TempDir(), t.TempDir()
|
|
secret := filepath.Join(external, "secret.txt")
|
|
if err := os.WriteFile(secret, []byte("original"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link, target := filepath.Join(out, "link"), external
|
|
pattern := "{{.Name}}/secret.txt"
|
|
if kind == "leaf" {
|
|
target = secret
|
|
pattern = "{{.Name}}"
|
|
}
|
|
if kind == "root" {
|
|
link = filepath.Join(out, "root")
|
|
out = link
|
|
pattern = "{{.Name}}"
|
|
}
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Skip(err)
|
|
}
|
|
w, err := newTestWriter(t, "replacement", "schema", pattern, out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db := models.InitDatabase("links")
|
|
db.Schemas = []*models.Schema{models.InitSchema("first"), models.InitSchema("link")}
|
|
if err := w.WriteDatabase(db); err == nil {
|
|
t.Fatal("expected symlink rejection")
|
|
}
|
|
got, err := os.ReadFile(secret)
|
|
if err != nil || string(got) != "original" {
|
|
t.Fatalf("symlink target changed: %q %v", got, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(out, "first")); !os.IsNotExist(err) {
|
|
t.Fatalf("preflight must precede writes: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMultiFileNewDirectoryAndNestedFiles(t *testing.T) {
|
|
out := filepath.Join(t.TempDir(), "new-output")
|
|
w, err := newTestWriter(t, "{{.Table.Name}}", "table", "{{.ParentSchema.Name}}/{{.Name}}.txt", out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.WriteDatabase(modeDB()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(out, "a", "t1.txt"))
|
|
if err != nil || string(got) != "t1" {
|
|
t.Fatalf("nested output: %q %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestMultiFileRootAncestorSymlinkRejected(t *testing.T) {
|
|
parent, external := t.TempDir(), t.TempDir()
|
|
link := filepath.Join(parent, "link")
|
|
if err := os.Symlink(external, link); err != nil {
|
|
t.Skip(err)
|
|
}
|
|
w, err := newTestWriter(t, "content", "schema", "{{.Name}}.txt", filepath.Join(link, "new"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.WriteDatabase(modeDB()); err == nil {
|
|
t.Fatal("root ancestor symlink must fail")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(external, "new")); !os.IsNotExist(err) {
|
|
t.Fatalf("must not create external directories: %v", err)
|
|
}
|
|
}
|