Files
releases/v1/ast/annotations_test.go
T
Anders Eknert db035b09fc Add support for Go 1.27 & jsonv2 (#8947)
Makes OPA build and pass its tests on Go 1.27, while keeping Go 1.25 and
1.26 working. JSON output is unchanged on every supported version.

Go 1.27 json package honours `encoding.TextAppender`. Many v1 ast types
implement AppendText to build their Rego string cheaply, so on 1.27 they
would have marshalled as Rego text. Files built only with 1.27 now
implement MarshalJSONTo.

Library users should keep using `json.Marshal` etc. The MarshalJSONTo
methods are implementation details, are absent from 1.25 and 1.26
builds, and may change.

---------

Signed-off-by: Anders Eknert <anders.eknert@apple.com>
Signed-off-by: Charlie Egan <charlie_egan@apple.com>
Co-authored-by: Charlie Egan <charlie_egan@apple.com>
2026-07-30 17:41:28 +01:00

1375 lines
32 KiB
Go

// Copyright 2022 The OPA Authors. All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.
package ast
import (
"encoding/json"
"fmt"
"maps"
"runtime"
"strings"
"testing"
"weak"
)
func TestEntrypointAnnotationScopeRequirements(t *testing.T) {
tests := []struct {
note string
module string
expectError bool
expectScope string
}{
{
note: "package scope explicit",
module: `# METADATA
# entrypoint: true
# scope: package
package foo`,
expectError: false,
expectScope: "package",
},
{
note: "package scope implied",
module: `# METADATA
# entrypoint: true
package foo`,
expectError: false,
expectScope: "package",
},
{
note: "subpackages scope explicit",
module: `# METADATA
# entrypoint: true
# scope: subpackages
package foo`,
expectError: true,
},
{
note: "document scope explicit",
module: `package foo
# METADATA
# entrypoint: true
# scope: document
foo := true`,
expectError: false,
expectScope: "document",
},
{
note: "document scope implied",
module: `package foo
# METADATA
# entrypoint: true
foo := true`,
expectError: false,
expectScope: "document",
},
{
note: "rule scope explicit",
module: `package foo
# METADATA
# entrypoint: true
# scope: rule
foo := true`,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.note, func(t *testing.T) {
module, err := ParseModuleWithOpts("test.rego", tc.module, ParserOptions{ProcessAnnotation: true})
if err != nil {
if !tc.expectError {
t.Errorf("unexpected error: %v", err)
}
return
}
if tc.expectError {
t.Fatalf("expected error")
}
if tc.expectScope != module.Annotations[0].Scope {
t.Fatalf("expected scope %q, got %q", tc.expectScope, module.Annotations[0].Scope)
}
})
}
}
// Test of example code in docs/content/annotations.md
func ExampleAnnotationSet_Flatten() {
modules := [][]string{
{
"foo.rego", `# METADATA
# scope: subpackages
# organizations:
# - Acme Corp.
package foo`},
{
"mod", `# METADATA
# description: A couple of useful rules
package foo.bar
# METADATA
# title: My Rule P
p := 7`},
}
parsed := make([]*Module, 0, len(modules))
for _, entry := range modules {
pm, err := ParseModuleWithOpts(entry[0], entry[1], ParserOptions{ProcessAnnotation: true})
if err != nil {
panic(err)
}
parsed = append(parsed, pm)
}
as, err := BuildAnnotationSet(parsed)
if err != nil {
panic(err)
}
flattened := as.Flatten()
for _, entry := range flattened {
fmt.Printf("%v at %v has annotations %v\n",
entry.Path,
entry.Location,
entry.Annotations)
}
// Output:
// data.foo at foo.rego:5 has annotations {"organizations":["Acme Corp."],"scope":"subpackages"}
// data.foo.bar at mod:3 has annotations {"description":"A couple of useful rules","scope":"package"}
// data.foo.bar.p at mod:7 has annotations {"scope":"rule","title":"My Rule P"}
}
// Test of example code in docs/content/annotations.md
func ExampleAnnotationSet_Chain() {
modules := [][]string{
{
"foo.rego", `# METADATA
# scope: subpackages
# organizations:
# - Acme Corp.
package foo`},
{
"mod", `# METADATA
# description: A couple of useful rules
package foo.bar
# METADATA
# title: My Rule P
p := 7`},
}
parsed := make([]*Module, 0, len(modules))
for _, entry := range modules {
pm, err := ParseModuleWithOpts(entry[0], entry[1], ParserOptions{ProcessAnnotation: true})
if err != nil {
panic(err)
}
parsed = append(parsed, pm)
}
as, err := BuildAnnotationSet(parsed)
if err != nil {
panic(err)
}
rule := parsed[1].Rules[0]
flattened := as.Chain(rule)
for _, entry := range flattened {
fmt.Printf("%v at %v has annotations %v\n",
entry.Path,
entry.Location,
entry.Annotations)
}
// Output:
// data.foo.bar.p at mod:7 has annotations {"scope":"rule","title":"My Rule P"}
// data.foo.bar at mod:3 has annotations {"description":"A couple of useful rules","scope":"package"}
// data.foo at foo.rego:5 has annotations {"organizations":["Acme Corp."],"scope":"subpackages"}
}
func TestAnnotationSet_Flatten(t *testing.T) {
tests := []struct {
note string
modules map[string]string
expected []AnnotationsRef
}{
{
note: "no modules",
modules: map[string]string{},
expected: []AnnotationsRef{},
},
{
note: "simple module (all annotation types)",
modules: map[string]string{
"module": `# METADATA
# title: pkg
# description: pkg
# organizations:
# - pkg
# related_resources:
# - https://pkg
# authors:
# - pkg
# schemas:
# - input: {"type": "boolean"}
# custom:
# pkg: pkg
package test
# METADATA
# scope: document
# title: doc
# description: doc
# organizations:
# - doc
# related_resources:
# - https://doc
# authors:
# - doc
# schemas:
# - input: {"type": "integer"}
# custom:
# doc: doc
# METADATA
# title: rule
# description: rule
# organizations:
# - rule
# related_resources:
# - https://rule
# authors:
# - rule
# schemas:
# - input: {"type": "string"}
# custom:
# rule: rule
p = 1`,
},
expected: []AnnotationsRef{
{
Path: MustParseRef("data.test"),
Location: &Location{File: "module", Row: 14},
Annotations: &Annotations{
Scope: "package",
Title: "pkg",
Description: "pkg",
Organizations: []string{"pkg"},
RelatedResources: []*RelatedResourceAnnotation{
{
Ref: mustParseURL("https://pkg"),
},
},
Authors: []*AuthorAnnotation{
{
Name: "pkg",
},
},
Schemas: []*SchemaAnnotation{
schemaAnnotationFromMap("input", map[string]any{
"type": "boolean",
}),
},
Custom: map[string]any{
"pkg": "pkg",
},
},
},
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "module", Row: 44},
Annotations: &Annotations{
Scope: "document",
Title: "doc",
Description: "doc",
Organizations: []string{"doc"},
RelatedResources: []*RelatedResourceAnnotation{
{
Ref: mustParseURL("https://doc"),
},
},
Authors: []*AuthorAnnotation{
{
Name: "doc",
},
},
Schemas: []*SchemaAnnotation{
schemaAnnotationFromMap("input", map[string]any{
"type": "integer",
}),
},
Custom: map[string]any{
"doc": "doc",
},
},
},
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "module", Row: 44},
Annotations: &Annotations{
Scope: "rule",
Title: "rule",
Description: "rule",
Organizations: []string{"rule"},
RelatedResources: []*RelatedResourceAnnotation{
{
Ref: mustParseURL("https://rule"),
},
},
Authors: []*AuthorAnnotation{
{
Name: "rule",
},
},
Schemas: []*SchemaAnnotation{
schemaAnnotationFromMap("input", map[string]any{
"type": "string",
}),
},
Custom: map[string]any{
"rule": "rule",
},
},
},
},
},
{
note: "multiple subpackages",
modules: map[string]string{
"root": `# METADATA
# scope: subpackages
# title: ROOT
package root`,
"root.foo": `# METADATA
# title: FOO
# scope: subpackages
package root.foo`,
"root.foo.baz": `# METADATA
# title: BAZ
package root.foo.baz`,
"root.bar": `# METADATA
# title: BAR
# scope: subpackages
package root.bar`,
"root.bar.baz": `# METADATA
# title: BAZ
package root.bar.baz`,
"root2": `# METADATA
# scope: subpackages
# title: ROOT2
package root2`,
},
expected: []AnnotationsRef{
{
Path: MustParseRef("data.root"),
Location: &Location{File: "root", Row: 4},
Annotations: &Annotations{
Scope: "subpackages",
Title: "ROOT",
},
},
{
Path: MustParseRef("data.root.bar"),
Location: &Location{File: "root.bar", Row: 4},
Annotations: &Annotations{
Scope: "subpackages",
Title: "BAR",
},
},
{
Path: MustParseRef("data.root.bar.baz"),
Location: &Location{File: "root.bar.baz", Row: 3},
Annotations: &Annotations{
Scope: "package",
Title: "BAZ",
},
},
{
Path: MustParseRef("data.root.foo"),
Location: &Location{File: "root.foo", Row: 4},
Annotations: &Annotations{
Scope: "subpackages",
Title: "FOO",
},
},
{
Path: MustParseRef("data.root.foo.baz"),
Location: &Location{File: "root.foo.baz", Row: 3},
Annotations: &Annotations{
Scope: "package",
Title: "BAZ",
},
},
{
Path: MustParseRef("data.root2"),
Location: &Location{File: "root2", Row: 4},
Annotations: &Annotations{
Scope: "subpackages",
Title: "ROOT2",
},
},
},
},
{
note: "overlapping rule paths (same module)",
modules: map[string]string{
"mod": `package test
import rego.v1
# METADATA
# title: P1
p contains v if {v = 1}
# METADATA
# title: P2
p contains v if {v = 2}`,
},
expected: []AnnotationsRef{
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "mod", Row: 6},
Annotations: &Annotations{
Scope: "rule",
Title: "P1",
},
},
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "mod", Row: 10},
Annotations: &Annotations{
Scope: "rule",
Title: "P2",
},
},
},
},
{
note: "overlapping rule paths (different modules)",
modules: map[string]string{
"mod1": `package test
import rego.v1
# METADATA
# title: P1
p contains v if {v = 1}`,
"mod2": `package test
import rego.v1
# METADATA
# title: P2
p contains v if {v = 2}`,
},
expected: []AnnotationsRef{
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "mod1", Row: 6},
Annotations: &Annotations{
Scope: "rule",
Title: "P1",
},
},
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "mod2", Row: 6},
Annotations: &Annotations{
Scope: "rule",
Title: "P2",
},
},
},
},
{
note: "overlapping rule paths (different modules, rule head refs)",
modules: map[string]string{
"mod1": `package test.a
import rego.v1
# METADATA
# title: P1
b.c.p[v] if {v = 1}`,
"mod2": `package test
import rego.v1
# METADATA
# title: P2
a.b.c.p[v] if {v = 2}`,
},
expected: []AnnotationsRef{
{
Path: MustParseRef("data.test.a.b.c.p"),
Location: &Location{File: "mod1", Row: 6},
Annotations: &Annotations{
Scope: "rule",
Title: "P1",
},
},
{
Path: MustParseRef("data.test.a.b.c.p"),
Location: &Location{File: "mod2", Row: 6},
Annotations: &Annotations{
Scope: "rule",
Title: "P2",
},
},
},
},
}
for _, tc := range tests {
t.Run(tc.note, func(t *testing.T) {
compiler := MustCompileModulesWithOpts(tc.modules,
CompileOpts{ParserOptions: ParserOptions{ProcessAnnotation: true}})
as := compiler.GetAnnotationSet()
if as == nil {
t.Fatalf("Expected compiled AnnotationSet, got nil")
}
flattened := as.Flatten()
if len(flattened) != len(tc.expected) {
t.Fatalf("flattened AnnotationSet\n%v\ndoesn't match expected\n%v",
toJSON(flattened), toJSON(tc.expected))
}
for i, expected := range tc.expected {
a := flattened[i]
if !expected.Path.Equal(a.Path) {
t.Fatalf("path of AnnotationRef at %d '%v' doesn't match expected '%v'",
i, a.Path, expected.Path)
}
if expected.Location.File != a.Location.File || expected.Location.Row != a.Location.Row {
t.Fatalf("location of AnnotationRef at %d '%v' doesn't match expected '%v'",
i, a.Location, expected.Location)
}
if expected.Annotations.Compare(a.Annotations) != 0 {
t.Fatalf("annotations of AnnotationRef at %d\n%v\ndoesn't match expected\n%v",
i, a.Annotations, expected.Annotations)
}
}
})
}
}
func TestAnnotationSet_Chain(t *testing.T) {
tests := []struct {
note string
modules map[string]string
moduleToAnalyze string
ruleOnLineToAnalyze int
expected []AnnotationsRef
}{
{
note: "simple module (all annotation types)",
modules: map[string]string{
"module": `# METADATA
# title: pkg
# description: pkg
# organizations:
# - pkg
# related_resources:
# - https://pkg
# authors:
# - pkg
# schemas:
# - input.foo: {"type": "boolean"}
# custom:
# pkg: pkg
package test
# METADATA
# scope: document
# title: doc
# description: doc
# organizations:
# - doc
# related_resources:
# - https://doc
# authors:
# - doc
# schemas:
# - input.bar: {"type": "integer"}
# custom:
# doc: doc
# METADATA
# title: rule
# description: rule
# organizations:
# - rule
# related_resources:
# - https://rule
# authors:
# - rule
# schemas:
# - input.baz: {"type": "string"}
# custom:
# rule: rule
p = 1`,
},
moduleToAnalyze: "module",
ruleOnLineToAnalyze: 44,
expected: []AnnotationsRef{
{ // Rule annotation is always first
Path: MustParseRef("data.test.p"),
Location: &Location{File: "module", Row: 44},
Annotations: &Annotations{
Scope: "rule",
Title: "rule",
Description: "rule",
Organizations: []string{"rule"},
RelatedResources: []*RelatedResourceAnnotation{
{
Ref: mustParseURL("https://rule"),
},
},
Authors: []*AuthorAnnotation{
{
Name: "rule",
},
},
Schemas: []*SchemaAnnotation{
schemaAnnotationFromMap("input.baz", map[string]any{
"type": "string",
}),
},
Custom: map[string]any{
"rule": "rule",
},
},
},
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "module", Row: 44},
Annotations: &Annotations{
Scope: "document",
Title: "doc",
Description: "doc",
Organizations: []string{"doc"},
RelatedResources: []*RelatedResourceAnnotation{
{
Ref: mustParseURL("https://doc"),
},
},
Authors: []*AuthorAnnotation{
{
Name: "doc",
},
},
Schemas: []*SchemaAnnotation{
schemaAnnotationFromMap("input.bar", map[string]any{
"type": "integer",
}),
},
Custom: map[string]any{
"doc": "doc",
},
},
},
{
Path: MustParseRef("data.test"),
Location: &Location{File: "module", Row: 14},
Annotations: &Annotations{
Scope: "package",
Title: "pkg",
Description: "pkg",
Organizations: []string{"pkg"},
RelatedResources: []*RelatedResourceAnnotation{
{
Ref: mustParseURL("https://pkg"),
},
},
Authors: []*AuthorAnnotation{
{
Name: "pkg",
},
},
Schemas: []*SchemaAnnotation{
schemaAnnotationFromMap("input.foo", map[string]any{
"type": "boolean",
}),
},
Custom: map[string]any{
"pkg": "pkg",
},
},
},
},
},
{
note: "no annotations on rule",
modules: map[string]string{
"module": `# METADATA
# title: pkg
# description: pkg
package test
# METADATA
# scope: document
# title: doc
# description: doc
p = 1`,
},
moduleToAnalyze: "module",
ruleOnLineToAnalyze: 11,
expected: []AnnotationsRef{
{ // Rule entry is always first, even if no annotations are present
Path: MustParseRef("data.test.p"),
Location: &Location{File: "module", Row: 11},
Annotations: nil,
},
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "module", Row: 11},
Annotations: &Annotations{
Scope: "document",
Title: "doc",
Description: "doc",
},
},
{
Path: MustParseRef("data.test"),
Location: &Location{File: "module", Row: 4},
Annotations: &Annotations{
Scope: "package",
Title: "pkg",
Description: "pkg",
},
},
},
},
{
note: "multiple subpackages",
modules: map[string]string{
"root": `# METADATA
# scope: subpackages
# title: ROOT
package root`,
"root.foo": `# METADATA
# title: FOO
# scope: subpackages
package root.foo`,
"root.foo.bar": `# METADATA
# scope: subpackages
# description: subpackages scope applied to rule in other module
# title: BAR-sub
# METADATA
# title: BAR-other
# description: This metadata is on the path of the queried rule, and should show up in the result even though it's in a different module.
package root.foo.bar
# METADATA
# scope: document
# description: document scope applied to rule in other module
# title: P-doc
p = 1`,
"rule": `package root.foo.bar
# METADATA
# title: P
p = 1`,
},
moduleToAnalyze: "rule",
ruleOnLineToAnalyze: 5,
expected: []AnnotationsRef{
{
Path: MustParseRef("data.root.foo.bar.p"),
Location: &Location{File: "rule", Row: 5},
Annotations: &Annotations{
Scope: "rule",
Title: "P",
},
},
{
Path: MustParseRef("data.root.foo.bar.p"),
Location: &Location{File: "root.foo.bar", Row: 15},
Annotations: &Annotations{
Scope: "document",
Title: "P-doc",
Description: "document scope applied to rule in other module",
},
},
{
Path: MustParseRef("data.root.foo.bar"),
Location: &Location{File: "root.foo.bar", Row: 9},
Annotations: &Annotations{
Scope: "package",
Title: "BAR-other",
Description: "This metadata is on the path of the queried rule, and should show up in the result even though it's in a different module.",
},
},
{
Path: MustParseRef("data.root.foo.bar"),
Location: &Location{File: "root.foo.bar", Row: 9},
Annotations: &Annotations{
Scope: "subpackages",
Title: "BAR-sub",
Description: "subpackages scope applied to rule in other module",
},
},
{
Path: MustParseRef("data.root.foo"),
Location: &Location{File: "root.foo", Row: 4},
Annotations: &Annotations{
Scope: "subpackages",
Title: "FOO",
},
},
{
Path: MustParseRef("data.root"),
Location: &Location{File: "root", Row: 4},
Annotations: &Annotations{
Scope: "subpackages",
Title: "ROOT",
},
},
},
},
{
note: "multiple subpackages, refs in rule heads", // NOTE(sr): same as above, but last module's rule is `foo.bar.p` in package `root`
modules: map[string]string{
"root": `# METADATA
# scope: subpackages
# title: ROOT
package root`,
"root.foo": `# METADATA
# title: FOO
# scope: subpackages
package root.foo`,
"root.foo.bar": `# METADATA
# scope: subpackages
# description: subpackages scope applied to rule in other module
# title: BAR-sub
# METADATA
# title: BAR-other
# description: This metadata is on the path of the queried rule, but shouldn't show up in the result as it's in a different module.
package root.foo.bar
# METADATA
# scope: document
# description: document scope applied to rule in other module
# title: P-doc
p = 1`,
"rule": `# METADATA
# title: BAR
package root
# METADATA
# title: P
foo.bar.p = 1`,
},
moduleToAnalyze: "rule",
ruleOnLineToAnalyze: 7,
expected: []AnnotationsRef{
{
Path: MustParseRef("data.root.foo.bar.p"),
Location: &Location{File: "rule", Row: 7},
Annotations: &Annotations{
Scope: "rule",
Title: "P",
},
},
{
Path: MustParseRef("data.root.foo.bar.p"),
Location: &Location{File: "root.foo.bar", Row: 15},
Annotations: &Annotations{
Scope: "document",
Title: "P-doc",
Description: "document scope applied to rule in other module",
},
},
{
Path: MustParseRef("data.root"),
Location: &Location{File: "rule", Row: 3},
Annotations: &Annotations{
Scope: "package",
Title: "BAR",
},
},
{
Path: MustParseRef("data.root"),
Location: &Location{File: "root", Row: 4},
Annotations: &Annotations{
Scope: "subpackages",
Title: "ROOT",
},
},
},
},
{
note: "multiple metadata blocks for single rule (order)",
modules: map[string]string{
"module": `package test
# METADATA
# title: One
# METADATA
# title: Two
# METADATA
# title: Three
# METADATA
# title: Four
p = true`,
},
moduleToAnalyze: "module",
ruleOnLineToAnalyze: 14,
expected: []AnnotationsRef{ // Rule annotations order is expected to start closest to the rule, moving out
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "module", Row: 14},
Annotations: &Annotations{
Scope: "rule",
Title: "Four",
},
},
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "module", Row: 14},
Annotations: &Annotations{
Scope: "rule",
Title: "Three",
},
},
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "module", Row: 14},
Annotations: &Annotations{
Scope: "rule",
Title: "Two",
},
},
{
Path: MustParseRef("data.test.p"),
Location: &Location{File: "module", Row: 14},
Annotations: &Annotations{
Scope: "rule",
Title: "One",
},
},
},
},
}
for _, tc := range tests {
t.Run(tc.note, func(t *testing.T) {
compiler := MustCompileModulesWithOpts(tc.modules,
CompileOpts{ParserOptions: ParserOptions{ProcessAnnotation: true}})
as := compiler.GetAnnotationSet()
if as == nil {
t.Fatalf("Expected compiled AnnotationSet, got nil")
}
m := compiler.Modules[tc.moduleToAnalyze]
if m == nil {
t.Fatalf("no such module: %s", tc.moduleToAnalyze)
}
var rule *Rule
for _, r := range m.Rules {
if r.Location.Row == tc.ruleOnLineToAnalyze {
rule = r
break
}
}
if rule == nil {
t.Fatalf("no rule found on line %d in module '%s'",
tc.ruleOnLineToAnalyze, tc.moduleToAnalyze)
}
chain := as.Chain(rule)
if len(chain) != len(tc.expected) {
t.Errorf("expected %d elements, got %d:", len(tc.expected), len(chain))
t.Fatalf("chained AnnotationSet\n%v\n\ndoesn't match expected\n\n%v",
toJSON(chain), toJSON(tc.expected))
}
for i, expected := range tc.expected {
a := chain[i]
if !expected.Path.Equal(a.Path) {
t.Fatalf("path of AnnotationRef at %d '%v' doesn't match expected '%v'",
i, a.Path, expected.Path)
}
if expected.Location.File != a.Location.File || expected.Location.Row != a.Location.Row {
t.Fatalf("location of AnnotationRef at %d '%v' doesn't match expected '%v'",
i, a.Location, expected.Location)
}
if expected.Annotations.Compare(a.Annotations) != 0 {
t.Fatalf("annotations of AnnotationRef at %d\n%v\n\ndoesn't match expected\n\n%v",
i, a.Annotations, expected.Annotations)
}
}
})
}
}
// Rules supplied by an ExternalRuleSource are not tracked in the outer
// compiler's AnnotationSet. Chain must fall back to rule.Annotations so that
// labels (and other annotation-driven features) remain reachable for them.
func TestAnnotationSet_Chain_FallbackToRuleAnnotations(t *testing.T) {
mod := MustParseModuleWithOpts(`package test
# METADATA
# scope: document
# title: doc
# labels:
# component: authz
# METADATA
# labels:
# id: allow-admin
allow if input.role == "admin"
`, ParserOptions{ProcessAnnotation: true})
rule := mod.Rules[0]
if len(rule.Annotations) != 2 {
t.Fatalf("expected rule.Annotations to carry rule+document scope (2 entries), got %d", len(rule.Annotations))
}
as, errs := BuildAnnotationSet(nil) // empty: rule is not tracked
if len(errs) > 0 {
t.Fatalf("unexpected errors: %v", errs)
}
chain := as.Chain(rule)
if len(chain) != 2 {
t.Fatalf("expected 2 entries from rule.Annotations fallback, got %d: %v", len(chain), toJSON(chain))
}
var sawRule, sawDoc bool
for _, ref := range chain {
if ref.Annotations == nil {
t.Fatalf("expected non-nil Annotations on fallback ref, got placeholder: %v", toJSON(ref))
}
switch ref.Annotations.Scope {
case annotationScopeRule:
sawRule = true
if got := ref.Annotations.Labels["id"]; got != "allow-admin" {
t.Errorf("rule-scope id label: want %q, got %v", "allow-admin", got)
}
case annotationScopeDocument:
sawDoc = true
if got := ref.Annotations.Labels["component"]; got != "authz" {
t.Errorf("document-scope component label: want %q, got %v", "authz", got)
}
}
}
if !sawRule || !sawDoc {
t.Errorf("expected rule- and document-scope entries; sawRule=%v sawDoc=%v", sawRule, sawDoc)
}
}
// MergedLabels must work for rules whose source module isn't tracked by the
// AnnotationSet, mirroring the Chain fallback behavior so that decision-log
// label aggregation keeps working for ExternalRuleSource rules.
func TestAnnotationSet_MergedLabels_ExternalRule(t *testing.T) {
mod := MustParseModuleWithOpts(`package test
# METADATA
# scope: document
# labels:
# component: authz
# severity: low
# METADATA
# labels:
# id: allow-admin
# severity: high
allow if input.role == "admin"
`, ParserOptions{ProcessAnnotation: true})
rule := mod.Rules[0]
as, errs := BuildAnnotationSet(nil) // empty: rule is not tracked
if len(errs) > 0 {
t.Fatalf("unexpected errors: %v", errs)
}
want := map[string]any{
"component": "authz",
"id": "allow-admin",
"severity": "high", // rule scope overrides document scope
}
labels, key := as.MergedLabels(rule)
if !maps.Equal(labels, want) {
t.Errorf("first call: labels = %v, want %v", labels, want)
}
if key == "" {
t.Error("first call: key should be non-empty for a rule with labels")
}
// Second call must return the cached entry.
labels2, key2 := as.MergedLabels(rule)
if !maps.Equal(labels2, want) {
t.Errorf("second call: labels = %v, want %v", labels2, want)
}
if key2 != key {
t.Errorf("second call: key changed: %q -> %q", key, key2)
}
}
func TestAnnotations_toObject(t *testing.T) {
annotations := Annotations{
Scope: annotationScopeRule,
Title: "A title",
Description: "A description",
Organizations: []string{
"Acme Corp.",
"Tyrell Corp.",
},
RelatedResources: []*RelatedResourceAnnotation{
{
Ref: mustParseURL("https://example.com"),
Description: "An example",
},
{
Ref: mustParseURL("https://another.example.com"),
},
},
Authors: []*AuthorAnnotation{
{
Name: "John Doe",
Email: "john@example.com",
},
{
Name: "Jane Doe",
},
{
Email: "jeff@example.com",
},
},
Schemas: []*SchemaAnnotation{
{
Path: MustParseRef("input.foo"),
Schema: MustParseRef("schema.a"),
},
schemaAnnotationFromMap("input.bar", map[string]any{
"type": "boolean",
}),
},
Custom: map[string]any{
"number": 42,
"float": 2.2,
"string": "foo bar baz",
"bool": true,
"list": []any{
"a", "b",
},
"list_of_lists": []any{
[]any{
"a", "b",
},
[]any{
"b", "c",
},
},
"list_of_maps": []any{
map[string]any{
"one": 1,
"two": 2,
},
map[string]any{
"two": 2,
"three": 3,
},
},
"map": map[string]any{
"nested_number": 1,
"nested_map": map[string]any{
"do": "re",
"mi": "fa",
},
"nested_list": []any{
1, 2, 3,
},
},
},
}
expected := NewObject(
Item(StringTerm("scope"), StringTerm(annotationScopeRule)),
Item(StringTerm("title"), StringTerm("A title")),
Item(StringTerm("description"), StringTerm("A description")),
Item(StringTerm("organizations"), ArrayTerm(
StringTerm("Acme Corp."),
StringTerm("Tyrell Corp."),
)),
Item(StringTerm("related_resources"), ArrayTerm(
ObjectTerm(
Item(StringTerm("ref"), StringTerm("https://example.com")),
Item(StringTerm("description"), StringTerm("An example")),
),
ObjectTerm(
Item(StringTerm("ref"), StringTerm("https://another.example.com")),
),
)),
Item(StringTerm("authors"), ArrayTerm(
ObjectTerm(
Item(StringTerm("name"), StringTerm("John Doe")),
Item(StringTerm("email"), StringTerm("john@example.com")),
),
ObjectTerm(
Item(StringTerm("name"), StringTerm("Jane Doe")),
),
ObjectTerm(
Item(StringTerm("email"), StringTerm("jeff@example.com")),
),
)),
Item(StringTerm("schemas"), ArrayTerm(
ObjectTerm(
Item(StringTerm("path"), ArrayTerm(StringTerm("input"), StringTerm("foo"))),
Item(StringTerm("schema"), ArrayTerm(StringTerm("schema"), StringTerm("a"))),
),
ObjectTerm(
Item(StringTerm("path"), ArrayTerm(StringTerm("input"), StringTerm("bar"))),
Item(StringTerm("definition"), ObjectTerm(
Item(StringTerm("type"), StringTerm("boolean")),
)),
),
)),
Item(StringTerm("custom"), ObjectTerm(
Item(StringTerm("number"), NumberTerm("42")),
Item(StringTerm("float"), NumberTerm("2.2")),
Item(StringTerm("string"), StringTerm("foo bar baz")),
Item(StringTerm("bool"), BooleanTerm(true)),
Item(StringTerm("list"), ArrayTerm(
StringTerm("a"),
StringTerm("b"),
)),
Item(StringTerm("list_of_lists"), ArrayTerm(
ArrayTerm(
StringTerm("a"),
StringTerm("b"),
),
ArrayTerm(
StringTerm("b"),
StringTerm("c"),
),
)),
Item(StringTerm("list_of_maps"), ArrayTerm(
ObjectTerm(
Item(StringTerm("one"), NumberTerm("1")),
Item(StringTerm("two"), NumberTerm("2")),
),
ObjectTerm(
Item(StringTerm("two"), NumberTerm("2")),
Item(StringTerm("three"), NumberTerm("3")),
),
)),
Item(StringTerm("map"), ObjectTerm(
Item(StringTerm("nested_number"), NumberTerm("1")),
Item(StringTerm("nested_map"), ObjectTerm(
Item(StringTerm("do"), StringTerm("re")),
Item(StringTerm("mi"), StringTerm("fa")),
)),
Item(StringTerm("nested_list"), ArrayTerm(
NumberTerm("1"),
NumberTerm("2"),
NumberTerm("3"),
)),
)),
)),
)
obj, err := annotations.toObject()
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
if Compare(*obj, expected) != 0 {
t.Fatalf("object generated from annotations\n\n%v\n\ndoesn't match expected\n\n%v",
*obj, expected)
}
}
func toJSON(v any) string {
b, _ := json.MarshalIndent(v, "", " ")
return string(b)
}
func schemaAnnotationFromMap(path string, def map[string]any) *SchemaAnnotation {
var p any = def
return &SchemaAnnotation{Path: MustParseRef(path), Definition: &p}
}
// TestAnnotationSet_MergedLabels_Collectable verifies that an AnnotationSet
// can be garbage-collected after it goes out of scope, even when MergedLabels
// has been called (which populates the internal mergedLabels cache).
//
// Regression test for a memory leak where the cleanup closure registered by
// runtime.AddCleanup captured the AnnotationSet itself. The AnnotationSet
// holds strong references to all modules (and their rules) via as.modules, so
// the cleanup could never fire: rule → cleanup closure → AnnotationSet →
// modules → rule. Each bundle reload left the old AnnotationSet permanently
// alive, causing unbounded heap growth.
func TestAnnotationSet_MergedLabels_Collectable(t *testing.T) {
const src = `package test
# METADATA
# labels:
# tier: fast
allow if true
`
var watch weak.Pointer[AnnotationSet]
func() {
mod := MustParseModuleWithOpts(src, ParserOptions{ProcessAnnotation: true})
as, errs := BuildAnnotationSet([]*Module{mod})
if len(errs) > 0 {
t.Fatalf("BuildAnnotationSet: %v", errs)
}
// Populate the mergedLabels cache for all rules.
for _, r := range mod.Rules {
as.MergedLabels(r)
}
watch = weak.Make(as)
// mod and as go out of scope here.
}()
// Two GC cycles: one to discover unreachable objects, one to collect them.
runtime.GC()
runtime.GC()
if watch.Value() != nil {
t.Fatal("AnnotationSet was not garbage-collected: mergedLabels cache likely holds a retaining cycle")
}
}
func TestAnnotations_StringDeterministic(t *testing.T) {
a := &Annotations{
Scope: "rule",
Description: "<b>&</b>",
Custom: map[string]any{
"zeta": 1, "alpha": 2, "mu": 3, "beta": 4, "omega": 5,
},
}
exp := a.String()
for i := range 10 {
if got := a.String(); got != exp {
t.Fatalf("String() is not deterministic across calls:\ncall 0: %s\ncall %d: %s", exp, i+1, got)
}
}
if raw := "<b>&</b>"; strings.Contains(exp, raw) {
t.Fatalf("expected HTML characters to be escaped, but found raw %s in %s", raw, exp)
}
if escaped := `\u003cb\u003e\u0026\u003c/b\u003e`; !strings.Contains(exp, escaped) {
t.Fatalf("expected HTML characters to be escaped as %s, got %s", escaped, exp)
}
}