Files
summercms/modules/kiosk/manifest_test.go
Jakub Zych 9109e5b139 test(kiosk): manifest, identity, version and client unit tests
- TestReadIdentity: literal, const, chained, cross-package, alias,
  other-module and Requires shapes; refusals naming ID()/Requires()
- TestDerive, TestManifestFormat, TestCheck, TestVersions,
  TestRegistryClient (httptest: escaping, 404, envelopes, cap, redirect)
- runnable ExampleMarshal and ExampleNewestCompatible
- party TestDescribe: copy of the plugin list, publish once per app
2026-10-29 21:22:47 +01:00

264 lines
8.9 KiB
Go

package kiosk
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// fullManifest is a valid manifest with every field set.
func fullManifest() Manifest {
return Manifest{
Schema: SchemaVersion,
ID: "acme.shop",
Module: "example.test/sm-shop-plugin",
Package: "example.test/sm-shop-plugin/shop",
Name: "Shop",
Description: "Products, carts and orders.",
Framework: "v0.6.0",
Requires: Requires{"acme.user": "v0.1.2", "acme.payment": "", "acme.audit": "v1.0.0"},
Overrides: &Overrides{Name: "Shop"},
}
}
// TestManifestFormat pins the plugin.yaml bytes, the parse rules and the
// validation table.
func TestManifestFormat(t *testing.T) {
t.Run("marshal is stable and ordered", func(t *testing.T) {
want := Header + "\n" +
"schema: 1\n" +
"id: acme.shop\n" +
"module: example.test/sm-shop-plugin\n" +
"package: example.test/sm-shop-plugin/shop\n" +
"name: Shop\n" +
"description: Products, carts and orders.\n" +
"framework: v0.6.0\n" +
"requires:\n" +
" acme.audit: v1.0.0\n" +
" acme.payment: \"\"\n" +
" acme.user: v0.1.2\n" +
"overrides:\n" +
" name: Shop\n"
for i := 0; i < 20; i++ {
got, err := Marshal(fullManifest())
if err != nil {
t.Fatal(err)
}
if string(got) != want {
t.Fatalf("run %d: Marshal =\n%s\nwant\n%s", i, got, want)
}
}
})
t.Run("empty requires and overrides are left out", func(t *testing.T) {
m := fullManifest()
m.Requires = Requires{}
m.Overrides = &Overrides{}
got, err := Marshal(m)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(got), "requires") || strings.Contains(string(got), "overrides") {
t.Errorf("Marshal kept empty blocks:\n%s", got)
}
m.Requires = nil
m.Overrides = nil
again, err := Marshal(m)
if err != nil {
t.Fatal(err)
}
if string(again) != string(got) {
t.Errorf("nil and empty differ:\n%s\n%s", got, again)
}
})
t.Run("parse round-trips", func(t *testing.T) {
raw, err := Marshal(fullManifest())
if err != nil {
t.Fatal(err)
}
m, err := Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
again, err := Marshal(m)
if err != nil {
t.Fatal(err)
}
if string(again) != string(raw) {
t.Errorf("round trip changed the bytes:\n%s\n%s", raw, again)
}
if v, ok := m.Requires["acme.payment"]; !ok || v != "" {
t.Errorf("empty minimum lost: %v", m.Requires)
}
})
t.Run("parse refusals", func(t *testing.T) {
raw, err := Marshal(fullManifest())
if err != nil {
t.Fatal(err)
}
newer := strings.Replace(string(raw), "schema: 1", "schema: 2", 1)
if _, err := Parse([]byte(newer)); err == nil || !strings.Contains(err.Error(), "update summer") {
t.Errorf("newer schema: err = %v, want one saying to update summer", err)
}
if _, err := Parse([]byte("schema: [1\n")); err == nil || !strings.Contains(err.Error(), FileName) {
t.Errorf("broken YAML: err = %v", err)
}
bad := strings.Replace(string(raw), "id: acme.shop", "id: Acme.Shop", 1)
if _, err := Parse([]byte(bad)); err == nil || !strings.Contains(err.Error(), "vendor.plugin") {
t.Errorf("invalid id: err = %v", err)
}
// A field a later schema-1 writer adds is ignored, as documented
// on Parse: older summer binaries keep reading newer files.
extra := string(raw) + "homepage: https://example.test\n"
m, err := Parse([]byte(extra))
if err != nil {
t.Fatalf("unknown field: %v", err)
}
if m.ID != "acme.shop" {
t.Errorf("unknown field changed the manifest: %+v", m)
}
})
t.Run("validate", func(t *testing.T) {
if err := Validate(fullManifest()); err != nil {
t.Fatalf("Validate(full) = %v", err)
}
cases := []struct {
name string
mutate func(*Manifest)
want string
}{
{"schema zero", func(m *Manifest) { m.Schema = 0 }, "schema"},
{"schema newer", func(m *Manifest) { m.Schema = SchemaVersion + 1 }, "schema"},
{"id upper", func(m *Manifest) { m.ID = "Acme.shop" }, "id"},
{"id no vendor", func(m *Manifest) { m.ID = "shop" }, "id"},
{"id three parts", func(m *Manifest) { m.ID = "acme.shop.x" }, "id"},
{"id digit first", func(m *Manifest) { m.ID = "1acme.shop" }, "id"},
{"module empty", func(m *Manifest) { m.Module = "" }, "module"},
{"module dash", func(m *Manifest) { m.Module = "-x/y"; m.Package = m.Module }, "module"},
{"module space", func(m *Manifest) { m.Module = "example.test/a b"; m.Package = m.Module }, "module"},
{"module dotdot", func(m *Manifest) { m.Module = "example.test/../x"; m.Package = m.Module }, "module"},
{"module version", func(m *Manifest) { m.Module = "example.test/x@v1"; m.Package = m.Module }, "module"},
{"package shell", func(m *Manifest) { m.Package = "example.test/sm-shop-plugin/$(id)" }, "package"},
{"package outside", func(m *Manifest) { m.Package = "example.test/sm-shop-plugin-x" }, "not inside"},
{"name", func(m *Manifest) { m.Name = "\t" }, "name"},
{"description", func(m *Manifest) { m.Description = "" }, "description"},
{"framework bare", func(m *Manifest) { m.Framework = "0.6.0" }, "framework"},
{"framework empty", func(m *Manifest) { m.Framework = "" }, "framework"},
{"framework describe", func(m *Manifest) { m.Framework = "v0.6.0-3-gabcdef1" }, "framework"},
{"required id", func(m *Manifest) { m.Requires = Requires{"User": ""} }, "required id"},
{"required minimum", func(m *Manifest) { m.Requires = Requires{"acme.user": "main"} }, "minimum"},
}
for _, tc := range cases {
m := fullManifest()
tc.mutate(&m)
err := Validate(m)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: Validate = %v, want an error naming %q", tc.name, err, tc.want)
}
}
ok := []func(*Manifest){
func(m *Manifest) { m.Package = m.Module },
func(m *Manifest) { m.Framework = "v0.6.1-0.20261029101500-abcdefabcdef" },
func(m *Manifest) { m.Requires = Requires{"acme.user": "v0.1.3-0.20261029101500-abcdefabcdef"} },
func(m *Manifest) { m.Requires = nil; m.Overrides = nil },
}
for i, mutate := range ok {
m := fullManifest()
mutate(&m)
if err := Validate(m); err != nil {
t.Errorf("accepted case %d: Validate = %v", i, err)
}
}
})
}
// TestCheck covers the drift guard: missing, stale and fresh files, and
// CheckT's single Fatalf.
func TestCheck(t *testing.T) {
ctx := context.Background()
dir := filepath.Join(copyTestdata(t, "literal"), "literal")
path := filepath.Join(dir, FileName)
if err := Check(ctx, dir); err != nil {
t.Fatalf("Check on the committed fixture: %v", err)
}
var fresh fakeTB
CheckT(&fresh, dir)
if len(fresh.fatals) != 0 {
t.Fatalf("CheckT on a fresh file: %q", fresh.fatals)
}
committed, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
stale := strings.Replace(string(committed), "name: Blog", "name: Weblog", 1)
if err := os.WriteFile(path, []byte(stale), 0o644); err != nil {
t.Fatal(err)
}
var se *StaleError
err = Check(ctx, dir)
if !errors.As(err, &se) || se.Missing {
t.Fatalf("Check on an edited file = %v, want a stale *StaleError", err)
}
if se.Path != path || string(se.Got) != stale || string(se.Want) != string(committed) {
t.Errorf("StaleError = path %s got %q want %q", se.Path, se.Got, se.Want)
}
line := strings.Count(string(committed)[:strings.Index(string(committed), "name: Blog")], "\n") + 1
wantMsg := `line ` + strconv.Itoa(line) + ` is "name: Weblog", want "name: Blog"`
if !strings.Contains(err.Error(), wantMsg) {
t.Errorf("stale message = %q, want it to carry %q", err.Error(), wantMsg)
}
var staleTB fakeTB
CheckT(&staleTB, dir)
if len(staleTB.fatals) != 1 || !strings.Contains(staleTB.fatals[0], "summer plugin:manifest "+dir) {
t.Errorf("CheckT on a stale file: %q", staleTB.fatals)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
err = Check(ctx, dir)
if !errors.As(err, &se) || !se.Missing || !strings.Contains(err.Error(), "is missing") {
t.Fatalf("Check without the file = %v, want Missing", err)
}
var missingTB fakeTB
CheckT(&missingTB, dir)
if len(missingTB.fatals) != 1 {
t.Errorf("CheckT on a missing file: %q", missingTB.fatals)
}
var broken fakeTB
CheckT(&broken, t.TempDir())
if len(broken.fatals) != 1 {
t.Errorf("CheckT on a directory without a plugin: %q", broken.fatals)
}
t.Run("stale message", func(t *testing.T) {
cases := []struct {
want, got, msg string
}{
{"a\nb\n", "a\nc\n", `line 2 is "c", want "b"`},
{"a\nb\n", "a\nb\nextra\n", `line 3 is "extra", want ""`},
{"a\nb\nc\n", "a\n", `line 2 is "", want "b"`},
}
for _, c := range cases {
e := &StaleError{Path: "p/plugin.yaml", Want: []byte(c.want), Got: []byte(c.got)}
if !strings.Contains(e.Error(), c.msg) || !strings.HasPrefix(e.Error(), "kiosk: p/plugin.yaml is stale") {
t.Errorf("StaleError(%q, %q) = %q, want %q", c.want, c.got, e.Error(), c.msg)
}
}
same := &StaleError{Path: "p", Want: []byte("x"), Got: []byte("x")}
if same.Error() != "kiosk: p is stale" {
t.Errorf("equal bytes: %q", same.Error())
}
})
}