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