Files
relspecgo/pkg/writers/template/writer_modes_test.go
T

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)
}
}