// Copyright 2018 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 bundle

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"maps"
	"path/filepath"
	"reflect"
	"slices"
	"strings"
	"testing"
	"testing/fstest"

	"github.com/open-policy-agent/opa/internal/file/archive"
	"github.com/open-policy-agent/opa/v1/ast"
)

func TestManifestAddRoot(t *testing.T) {
	m := Manifest{Roots: &[]string{}}
	m.AddRoot("x/y")
	m.AddRoot("y/z")
	exp, act := stringSet{"x/y": struct{}{}, "y/z": struct{}{}}, m.rootSet()
	if !act.Equal(exp) {
		t.Fatalf("expected roots to be %v, got %v", exp, act)
	}
}

func TestManifestEqual(t *testing.T) {
	var m Manifest
	var n Manifest

	assertEqual := func() {
		t.Helper()
		if !m.Equal(n) {
			t.Fatal("expected manifests to be equal")
		}
	}

	assertNotEqual := func() {
		t.Helper()
		if m.Equal(n) {
			t.Fatal("expected manifests to be different")
		}
	}

	assertEqual()

	n.Revision = "xxx"
	assertNotEqual()

	m.Revision = "xxx"
	assertEqual()

	n.WasmResolvers = append(n.WasmResolvers, WasmResolver{})
	assertNotEqual()

	m.WasmResolvers = append(m.WasmResolvers, WasmResolver{})
	assertEqual()

	n.WasmResolvers[0].Module = "yyy"
	assertNotEqual()

	m.WasmResolvers[0].Module = "yyy"
	assertEqual()

	n.Metadata = map[string]any{
		"foo": "bar",
	}
	assertNotEqual()

	m.Metadata = map[string]any{
		"foo": "bar",
	}
	assertEqual()

	// rego-version

	n.RegoVersion = new(1)
	assertNotEqual()

	m.RegoVersion = new(0)
	assertNotEqual()

	m.RegoVersion = new(1)
	assertEqual()

	n.FileRegoVersions = map[string]int{
		"foo": 1,
	}
	assertNotEqual()

	m.FileRegoVersions = map[string]int{
		"foo": 1,
	}
	assertEqual()

	n.FileRegoVersions["*/bar"] = 0
	assertNotEqual()

	m.FileRegoVersions["*/bar"] = 0
	assertEqual()
}

func TestBundleRegoVersion(t *testing.T) {
	b := Bundle{}

	if b.Manifest.RegoVersion != nil {
		t.Fatal("expected nil")
	}

	// No rego-version set, expect default
	if b.RegoVersion(ast.RegoV0) != ast.RegoV0 {
		t.Fatal("expected v0")
	}
	if b.RegoVersion(ast.RegoV1) != ast.RegoV1 {
		t.Fatal("expected v1")
	}

	// Set rego-version to v0
	b.SetRegoVersion(ast.RegoV0)

	if b.Manifest.RegoVersion == nil || *b.Manifest.RegoVersion != 0 {
		t.Fatal("expected v0")
	}

	if b.RegoVersion(ast.RegoV1) != ast.RegoV0 {
		t.Fatal("expected v0")
	}

	// Set rego-version to v1
	b.SetRegoVersion(ast.RegoV1)

	if b.Manifest.RegoVersion == nil || *b.Manifest.RegoVersion != 1 {
		t.Fatal("expected v1")
	}

	if b.RegoVersion(ast.RegoV0) != ast.RegoV1 {
		t.Fatal("expected v1")
	}

	// Set rego-version to v0-compat1
	b.SetRegoVersion(ast.RegoV0CompatV1)

	if b.Manifest.RegoVersion == nil || *b.Manifest.RegoVersion != 0 {
		t.Fatal("expected v0")
	}

	if b.RegoVersion(ast.RegoV1) != ast.RegoV0 {
		t.Fatal("expected v0")
	}
}

// TestManifestNumericRegoVersionForFileGlobSemantics pins how the
// file_rego_versions patterns are matched against module paths. The patterns
// are compiled without separators, so '*' crosses '/' -- but a wildcard still
// never stands in for a literal of the pattern, so a pattern with a '/*/'
// segment requires both slashes to be present in the path.
func TestManifestNumericRegoVersionForFileGlobSemantics(t *testing.T) {
	tests := []struct {
		note    string
		pattern string
		path    string
		want    bool
	}{
		{
			note:    "wildcard crosses the separator",
			pattern: "/example/*.rego",
			path:    "/example/authz/policy.rego",
			want:    true,
		},
		{
			note:    "wildcard does not stand in for a literal of the pattern",
			pattern: "/example/*/policy.rego",
			path:    "/example/policy.rego",
			want:    false,
		},
		{
			note:    "wildcard between literals",
			pattern: "/example/*/policy.rego",
			path:    "/example/authz/policy.rego",
			want:    true,
		},
		{
			note:    "super wildcard does not stand in for a literal either",
			pattern: "/example/**/policy.rego",
			path:    "/example/policy.rego",
			want:    false,
		},
		{
			note:    "leading wildcard matches the empty string",
			pattern: "*/policy.rego",
			path:    "/policy.rego",
			want:    true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.note, func(t *testing.T) {
			m := Manifest{FileRegoVersions: map[string]int{tc.pattern: 0}}

			got, err := m.numericRegoVersionForFile(tc.path)
			if err != nil {
				t.Fatal(err)
			}

			// A nil version means no pattern matched: RegoVersion is unset, so
			// the resolver has nothing to fall back to.
			if matched := got != nil; matched != tc.want {
				t.Errorf("pattern %q against %q: expected match=%v, got %v",
					tc.pattern, tc.path, tc.want, matched)
			}
		})
	}
}

// TestManifestNumericRegoVersionForFileInvalidPattern ensures a malformed
// pattern is reported rather than silently ignored. Note that gobwas/glob
// v0.2.3 accepted "{a,b" and "a\\"; v1.0.0 rejects both.
func TestManifestNumericRegoVersionForFileInvalidPattern(t *testing.T) {
	for _, pattern := range []string{"{a,b", `a\`, "[]"} {
		t.Run(pattern, func(t *testing.T) {
			m := Manifest{FileRegoVersions: map[string]int{pattern: 0}}

			if _, err := m.numericRegoVersionForFile("/example/policy.rego"); err == nil {
				t.Fatalf("expected error for pattern %q", pattern)
			} else if exp := "failed to compile glob pattern " + pattern; !strings.HasPrefix(err.Error(), exp) {
				t.Errorf("expected error starting with %q, got %q", exp, err.Error())
			}
		})
	}
}

// TestManifestNumericRegoVersionForFileDeterministic is a regression test
// that ensures that resolving a file's rego-version is stable across
// invocations when multiple patterns match the same path.
//
// The resolution for overlapping patterns is documented as undefined, but
// it should not vary from run to run for the same manifest.
//
// We test this by loading up several overlapping patterns, and resolving
// the file rego-versions multiple times. If there is any non-determinism
// in how patterns are selected, this test should surface it.
func TestManifestNumericRegoVersionForFileDeterministic(t *testing.T) {
	const path = "/example/policy.rego"

	// Every pattern matches path; the version value identifies which pattern
	// the resolver selected. (glob.Compile is called without separators, so
	// '*' crosses '/'.) numericRegoVersionForFile does not validate the
	// version range, so distinct ints are fine for probing which pattern won.
	patterns := map[string]int{
		"**":                0,
		"*":                 1,
		"/**":               2,
		"/example/**":       3,
		"/example/*":        4,
		"/example/*.rego":   5,
		"**/policy.rego":    6,
		"/example/policy.*": 7,
	}

	var first *int
	for i := range 64 {
		// Fresh manifest each iteration so the resolver runs afresh.
		m := Manifest{FileRegoVersions: maps.Clone(patterns)}
		got, err := m.numericRegoVersionForFile(path)
		if err != nil {
			t.Fatal(err)
		}
		if got == nil {
			t.Fatalf("iteration %d: expected a match, got nil", i)
		}
		if first == nil {
			first = got
		} else if *got != *first {
			t.Fatalf("rego-version for %q is not stable across runs: got %d and %d (overlapping-pattern resolution must be deterministic)", path, *first, *got)
		}
	}

	// Check and make sure lexical sorting is in place for patterns.
	if want := patterns[slices.Min(slices.Collect(maps.Keys(patterns)))]; first == nil || *first != want {
		t.Fatalf("expected lexically-first matching pattern to win (version %d), got %v", want, first)
	}
}

func TestRead(t *testing.T) {
	for _, useMemoryFS := range []bool{false, true} {
		testReadBundle(t, "", useMemoryFS)
	}
}

func TestReadWithBaseDir(t *testing.T) {
	for _, useMemoryFS := range []bool{false, true} {
		testReadBundle(t, "/foo/bar", useMemoryFS)
	}
}

func TestRead_DefaultRegoVersion(t *testing.T) {
	tests := []struct {
		note    string
		module  string
		expErrs []string
	}{
		{
			note: "v0",
			module: `package example

p[x] {
	x := "a"
}`,
			expErrs: []string{
				"test.rego:3: rego_parse_error: `if` keyword is required before rule body",
				"test.rego:3: rego_parse_error: `contains` keyword is required for partial set rules",
			},
		},
		{
			note: "rego.v1 import",
			module: `package example
import rego.v1

p contains x if {
	x := "a"
}`,
		},
		{
			note: "v1", // v1 is the default rego-version
			module: `package example

p contains x if {
	x := "a"
}`,
		},
	}

	for _, tc := range tests {
		t.Run(tc.note, func(t *testing.T) {
			module := tc.module
			files := [][2]string{
				{"test.rego", module},
			}

			buf := archive.MustWriteTarGz(files)
			loader := NewTarballLoaderWithBaseURL(buf, "")
			br := NewCustomReader(loader)

			bundle, err := br.Read()

			if len(tc.expErrs) > 0 {
				if err == nil {
					t.Fatalf("Expected error(s):\n\n%v\n\nbut got nil", tc.expErrs)
				}

				for _, expErr := range tc.expErrs {
					if !strings.Contains(err.Error(), expErr) {
						t.Fatalf("Expected error:\n\n%s\n\nbut got:\n\n%s", expErr, err)
					}
				}
			} else {
				if err != nil {
					t.Fatalf("Unexpected error: %s", err)
				}

				if len(bundle.Modules) != 1 {
					t.Fatalf("expected 1 module but got %d", len(bundle.Modules))
				}
			}
		})
	}
}

func TestRead_LogicalKeywords(t *testing.T) {
	v1Module := `package example
		import future.keywords.and
		import future.keywords.or

		allow if {
			input.user == "alice" or (input.role == "admin" and input.verified)
		}`

	v0Module := `package example
		import future.keywords.and
		import future.keywords.or

		allow {
			input.user == "alice" or (input.role == "admin" and input.verified)
		}`

	tests := []struct {
		note         string
		files        [][2]string
		regoVersion  ast.RegoVersion
		capabilities *ast.Capabilities
		expErrs      []string
	}{
		{
			note:  "per-keyword imports",
			files: [][2]string{{"test.rego", v1Module}},
		},
		{
			note: "wildcard future.keywords import",
			files: [][2]string{{"test.rego", `package example
				import future.keywords

				allow if {
					input.user == "alice" or (input.role == "admin" and input.verified)
				}`},
			},
		},
		{
			note: "no import",
			files: [][2]string{{"test.rego", `package example

				allow if {
					input.user == "alice" or input.role == "admin"
				}`},
			},
			expErrs: []string{"test.rego:4: rego_parse_error: unexpected identifier token"},
		},
		{
			note: "v0 bundle manifest",
			files: [][2]string{
				{"/.manifest", `{"roots": [""], "rego_version": 0}`},
				{"test.rego", v0Module},
			},
		},
		{
			note: "v0 bundle manifest, rego.v1 import",
			files: [][2]string{
				{"/.manifest", `{"roots": [""], "rego_version": 0}`},
				{"test.rego", `package example
					import future.keywords.and
					import future.keywords.or
					import rego.v1

					allow if {
						input.user == "alice" or (input.role == "admin" and input.verified)
					}`,
				},
			},
		},
		{
			note: "per-file rego version",
			files: [][2]string{
				{"/.manifest", `{"roots": [""], "rego_version": 1, "file_rego_versions": {"/test.rego": 0}}`},
				{"test.rego", v0Module},
			},
		},
		{
			note:        "v0 reader rego-version",
			files:       [][2]string{{"test.rego", v0Module}},
			regoVersion: ast.RegoV0,
		},
		{
			note:         "capabilities without and/or",
			files:        [][2]string{{"test.rego", v1Module}},
			capabilities: capabilitiesWithoutFutureKeywords(t, "and", "or"),
			expErrs:      []string{"rego_parse_error: unexpected keyword, must be one of [contains every if in not]"},
		},
	}

	for _, tc := range tests {
		t.Run(tc.note, func(t *testing.T) {
			buf := archive.MustWriteTarGz(tc.files)
			br := NewCustomReader(NewTarballLoaderWithBaseURL(buf, ""))

			if tc.regoVersion != ast.RegoUndefined {
				br = br.WithRegoVersion(tc.regoVersion)
			}
			if tc.capabilities != nil {
				br = br.WithCapabilities(tc.capabilities)
			}

			bundle, err := br.Read()

			if len(tc.expErrs) > 0 {
				if err == nil {
					t.Fatalf("Expected error(s):\n\n%v\n\nbut got nil", tc.expErrs)
				}
				for _, expErr := range tc.expErrs {
					if !strings.Contains(err.Error(), expErr) {
						t.Fatalf("Expected error:\n\n%s\n\nbut got:\n\n%s", expErr, err)
					}
				}
				return
			}

			if err != nil {
				t.Fatalf("Unexpected error: %s", err)
			}
			if len(bundle.Modules) != 1 {
				t.Fatalf("expected 1 module but got %d", len(bundle.Modules))
			}

			// The keywords must survive as logical nodes, not be parsed as
			// something else that happens to be valid.
			var ands, ors int
			ast.WalkExprs(bundle.Modules[0].Parsed, func(e *ast.Expr) bool {
				switch {
				case e.IsAnd():
					ands++
				case e.IsOr():
					ors++
				}
				return false
			})
			if ands != 1 || ors != 1 {
				t.Fatalf("expected 1 and-expression and 1 or-expression, got %d and %d in:\n\n%v",
					ands, ors, bundle.Modules[0].Parsed)
			}
		})
	}
}

func capabilitiesWithoutFutureKeywords(tb testing.TB, kws ...string) *ast.Capabilities {
	tb.Helper()

	caps := ast.CapabilitiesForThisVersion()
	caps.FutureKeywords = slices.DeleteFunc(caps.FutureKeywords, func(kw string) bool {
		return slices.Contains(kws, kw)
	})

	return caps
}

func TestReadWithSizeLimit(t *testing.T) {

	buf := archive.MustWriteTarGz([][2]string{
		{"data.json", `"foo"`},
	})

	loader := NewTarballLoaderWithBaseURL(buf, "")
	br := NewCustomReader(loader).WithSizeLimitBytes(4)

	_, err := br.Read()
	if err == nil || err.Error() != "bundle file 'data.json' size (5 bytes) exceeded max size (4 bytes)" {
		t.Fatal("expected error but got:", err)
	}

	buf = archive.MustWriteTarGz([][2]string{
		{".signatures.json", `"foo"`},
	})

	loader = NewTarballLoaderWithBaseURL(buf, "")
	br = NewCustomReader(loader).WithSizeLimitBytes(4)

	_, err = br.Read()
	if err == nil || err.Error() != "bundle file '.signatures.json' size (5 bytes) exceeded max size (4 bytes)" {
		t.Fatal("expected error but got:", err)
	}
}

func TestReadBundleInLazyMode(t *testing.T) {
	files := [][2]string{
		{"/a/b/c/data.json", "[1,2,3]"},
		{"/a/b/d/data.json", "true"},
		{"/a/b/y/data.yaml", `foo: 1`},
		{"/example/example.rego", `package example`},
		{"/data.json", `{"x": {"y": true}, "a": {"b": {"z": true}}}`},
		{"/.manifest", `{"revision": "foo", "roots": ["example"]}`}, // data is outside roots but validation skipped in lazy mode
	}

	buf := archive.MustWriteTarGz(files)
	loader := NewTarballLoaderWithBaseURL(buf, "")
	br := NewCustomReader(loader).WithLazyLoadingMode(true)

	bundle, err := br.Read()
	if err != nil {
		t.Fatal(err)
	}

	if len(bundle.Data) != 0 {
		t.Fatal("expected the bundle object to contain no data")
	}

	if len(bundle.Raw) == 0 {
		t.Fatal("raw bundle bytes not set on bundle object")
	}
}

func TestReadWithBundleEtag(t *testing.T) {

	files := [][2]string{
		{"/.manifest", `{"revision": "quickbrownfaux"}`},
	}

	buf := archive.MustWriteTarGz(files)
	bundle, err := NewReader(buf).WithBundleEtag("foo").Read()
	if err != nil {
		t.Fatal(err)
	}

	if bundle.Etag != "foo" {
		t.Fatalf("Expected bundle etag foo but got %v\n", bundle.Etag)
	}
}

func testReadBundle(t *testing.T, baseDir string, useMemoryFS bool) {
	module := `package example`
	if useMemoryFS && baseDir == "" {
		baseDir = "."
	}

	modulePath := "/example/example.rego"
	if baseDir != "" {
		modulePath = filepath.Join(baseDir, modulePath)
	}

	legacyWasmModulePath := "/policy.wasm"
	if baseDir != "" {
		legacyWasmModulePath = filepath.Join(baseDir, legacyWasmModulePath)
	}

	wasmResolverPath := "/authz/allow/policy.wasm"
	fullWasmResolverPath := wasmResolverPath
	if baseDir != "" {
		fullWasmResolverPath = filepath.Join(baseDir, wasmResolverPath)
	}

	files := [][2]string{
		{"/a/b/c/data.json", "[1,2,3]"},
		{"/a/b/d/data.json", "true"},
		{"/a/b/y/data.yaml", `foo: 1`},
		{"/a/b/g/data.yml", "1"},
		{"/example/example.rego", `package example`},
		{"/policy.wasm", `legacy-wasm-module`},
		{wasmResolverPath, `wasm-module`},
		{"/data.json", `{"x": {"y": true}, "a": {"b": {"z": true}}}`},
		{"/.manifest", fmt.Sprintf(`{"wasm":[{"entrypoint": "authz/allow", "module": "%s"}]}`, fullWasmResolverPath)},
	}

	buf := archive.MustWriteTarGz(files)
	var loader DirectoryLoader
	if useMemoryFS {
		fsys := make(fstest.MapFS, 1)
		fsys["test.tar"] = &fstest.MapFile{Data: buf.Bytes()}
		fh, err := fsys.Open("test.tar")
		if err != nil {
			t.Fatalf("Unexpected error: %s", err)
		}
		loader = NewTarballLoaderWithBaseURL(fh, baseDir)
	} else {
		loader = NewTarballLoaderWithBaseURL(buf, baseDir)
	}
	br := NewCustomReader(loader).WithBaseDir(baseDir)

	bundle, err := br.Read()
	if err != nil {
		t.Fatal(err)
	}

	expManifest := Manifest{}
	expManifest.Init()
	expManifest.WasmResolvers = []WasmResolver{
		{
			Entrypoint: "authz/allow",
			Module:     fullWasmResolverPath,
		},
	}

	exp := Bundle{
		Manifest: expManifest,
		Data: map[string]any{
			"a": map[string]any{
				"b": map[string]any{
					"c": []any{json.Number("1"), json.Number("2"), json.Number("3")},
					"d": true,
					"g": json.Number("1"),
					"y": map[string]any{
						"foo": json.Number("1"),
					},
					"z": true,
				},
			},
			"x": map[string]any{
				"y": true,
			},
		},
		Modules: []ModuleFile{
			{
				URL:    modulePath,
				Path:   modulePath,
				Parsed: ast.MustParseModule(module),
				Raw:    []byte(module),
			},
		},
		WasmModules: []WasmModuleFile{
			{
				URL:  legacyWasmModulePath,
				Path: legacyWasmModulePath,
				Raw:  []byte(`legacy-wasm-module`),
			},
			{
				URL:         fullWasmResolverPath,
				Path:        fullWasmResolverPath,
				Raw:         []byte("wasm-module"),
				Entrypoints: []ast.Ref{ast.MustParseRef("data.authz.allow")},
			},
		},
	}

	if !exp.Equal(bundle) {
		t.Fatalf("\nExp: %+v\nGot: %+v", exp, bundle)
	}
}

func TestReadWithManifest(t *testing.T) {
	files := [][2]string{
		{"/.manifest", `{"revision": "quickbrownfaux"}`},
	}
	buf := archive.MustWriteTarGz(files)
	bundle, err := NewReader(buf).Read()
	if err != nil {
		t.Fatal(err)
	}
	if bundle.Manifest.Revision != "quickbrownfaux" {
		t.Fatalf("Unexpected manifest.revision value: %v", bundle.Manifest.Revision)
	}
}

func TestReadRejectsDuplicateManifests(t *testing.T) {
	pbBytes, err := marshalManifestProto(&Manifest{Revision: "from-proto"})
	if err != nil {
		t.Fatal(err)
	}

	cases := []struct {
		note  string
		files [][2]string
	}{
		{
			note: "json + proto manifest",
			files: [][2]string{
				{"/.manifest", `{"revision": "from-json"}`},
				{"/.manifest.pb", string(pbBytes)},
			},
		},
		{
			note: "two json manifests at different paths (HasSuffix shadowing)",
			files: [][2]string{
				{"/.manifest", `{"revision": "from-root"}`},
				{"/sub/release.manifest", `{"revision": "from-sub"}`},
			},
		},
	}

	for _, tc := range cases {
		t.Run(tc.note, func(t *testing.T) {
			buf := archive.MustWriteTarGz(tc.files)
			_, err := NewReader(buf).Read()
			if err == nil {
				t.Fatal("expected error for duplicate manifests, got nil")
			}
			if !strings.Contains(err.Error(), "multiple manifest files") {
				t.Fatalf("unexpected error: %v", err)
			}
		})
	}
}

func TestManifestMetadata(t *testing.T) {
	files := [][2]string{
		{"/.manifest", `{
			"metadata": {
				"foo": {
					"version": "1.0.0"
				}
			}
		}`},
	}
	buf := archive.MustWriteTarGz(files)
	bundle, err := NewReader(buf).Read()
	if err != nil {
		t.Fatal(err)
	}
	if bundle.Manifest.Metadata["foo"] == nil {
		t.Fatal("Unexpected nil metadata key")
	}
	data, ok := bundle.Manifest.Metadata["foo"].(map[string]any)
	if !ok {
		t.Fatal("Unexpected structure in metadata")
	}
	if data["version"] != "1.0.0" {
		t.Fatalf("Unexpected metadata value: %v", data["version"])
	}
}

func TestReadWithManifestInData(t *testing.T) {
	files := [][2]string{
		{"/.manifest", `{"revision": "quickbrownfaux"}`},
	}
	buf := archive.MustWriteTarGz(files)
	bundle, err := NewReader(buf).IncludeManifestInData(true).Read()
	if err != nil {
		t.Fatal(err)
	}

	system := bundle.Data["system"].(map[string]any)
	b := system["bundle"].(map[string]any)
	m := b["manifest"].(map[string]any)

	if m["revision"] != "quickbrownfaux" {
		t.Fatalf("Unexpected manifest.revision value: %v. Expected: %v", m["revision"], "quickbrownfaux")
	}
}

func TestReadWithSignaturesSkipVerify(t *testing.T) {
	signedBadTokenHS256 := `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaWxlcyI6W3sibmFtZSI6Ii5tYW5pZmVzdCIsImhhc2giOiI2MDdhMmMzOGExNDQxZGI1OGQyY2I4Nzk4MmM0MmFhOTFhNDM0MmVmNDIyYTZiNTQyZWRkZWJlZWY2ZjA0MTJmIiwiYWxnb3JpdGhtIjoiU0hBLTI1NiJ9LHsibmFtZSI6ImEvYi9jL2RhdGEuanNvbiIsImhhc2giOiI0MmNmZTY3NjhiNTdiYjVmNzUwM2MxNjVjMjhkZDA3YWM1YjgxMzU1NGViYzg1MGYyY2MzNTg0M2U3MTM3YjFkIiwiYWxnb3JpdGhtIjoiU0hBLTI1NiJ9LHsibmFtZSI6Imh0dHAvcG9saWN5L3BvbGljeS5yZWdvIiwiaGFzaCI6ImE2MTVlZWFlZTIxZGU1MTc5ZGUwODBkZThjMzA1MmM4ZGE5MDExMzg0MDZiYTcxYzM4YzAzMjg0NWY3ZDU0ZjQiLCJhbGdvcml0aG0iOiJTSEEtMjU2In1dLCJpYXQiOjE1OTIyNDgwMjcsImlzcyI6IkpXVFNlcnZpY2UiLCJrZXlpZCI6ImZvbyIsInNjb3BlIjoid3JpdGUifQ.sQTuw9tBp6DvvQG-MXSxTzJA3hSnKYxjX5fnxiR22JA`

	files := [][2]string{
		{"/.manifest", `{"revision": "quickbrownfaux"}`},
		{"/.signatures.json", fmt.Sprintf(`{"signatures": ["%v"]}`, signedBadTokenHS256)},
		{"/a/b/c/data.json", "[1,2,3]"},
		{"/http/policy/policy.rego", `package example`},
	}

	vc := NewVerificationConfig(map[string]*KeyConfig{"foo": {Key: "secret", Algorithm: "HS256"}}, "", "write", nil)

	buf := archive.MustWriteTarGz(files)

	loader := NewTarballLoaderWithBaseURL(buf, "/foo/bar")
	reader := NewCustomReader(loader).WithBaseDir("/foo/bar").WithBundleVerificationConfig(vc).WithSkipBundleVerification(true)
	_, err := reader.Read()
	if err != nil {
		t.Fatalf("Unexpected error %v", err)
	}
}

func TestReadWithSignatures(t *testing.T) {

	signedTokenHS256 := `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyJ9.eyJmaWxlcyI6W3sibmFtZSI6Ii5tYW5pZmVzdCIsImhhc2giOiI1MDdhMmMzOGExNDQxZGI1OGQyY2I4Nzk4MmM0MmFhOTFhNDM0MmVmNDIyYTZiNTQyZWRkZWJlZWY2ZjA0MTJmIiwiYWxnb3JpdGhtIjoiU0hBLTI1NiJ9LHsibmFtZSI6ImEvYi9jL2RhdGEuanNvbiIsImhhc2giOiI0MmNmZTY3NjhiNTdiYjVmNzUwM2MxNjVjMjhkZDA3YWM1YjgxMzU1NGViYzg1MGYyY2MzNTg0M2U3MTM3YjFkIiwiYWxnb3JpdGhtIjoiU0hBLTI1NiJ9LHsibmFtZSI6Imh0dHAvcG9saWN5L3BvbGljeS5yZWdvIiwiaGFzaCI6ImE2MTVlZWFlZTIxZGU1MTc5ZGUwODBkZThjMzA1MmM4ZGE5MDExMzg0MDZiYTcxYzM4YzAzMjg0NWY3ZDU0ZjQiLCJhbGdvcml0aG0iOiJTSEEtMjU2In1dLCJpYXQiOjE1OTIyNDgwMjcsImlzcyI6IkpXVFNlcnZpY2UiLCJrZXlpZCI6ImZvbyIsInNjb3BlIjoid3JpdGUifQ.grzWHYvyVS6LfWy0oiFTEJThKooOAwic8sexYaflzOM`
	otherSignedTokenHS256 := `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyJ9.eyJmaWxlcyI6W3sibmFtZSI6ImEvYi9jL2RhdGEuanNvbiIsImhhc2giOiJmOWNhYzA3MTQ3MDVkMjBkMWEyMDg4MDE4NWNkZWQ2ZTBmNmQwNDA2NjJkMmViYjA5NjFkM2Q5ZjMxN2Q4YWNiIn1dLCJpYXQiOjE1OTIyNDgwMjcsImlzcyI6IkpXVFNlcnZpY2UiLCJzY29wZSI6IndyaXRlIn0.WJhnUjwaVvckSgOd4QcVvKThN6oc99NiPiwHKYnoG7c`
	defaultSigner, _ := GetSigner(defaultSignerID)
	defaultVerifier, _ := GetVerifier(defaultVerifierID)
	if err := RegisterSigner("_bar", defaultSigner); err != nil {
		t.Fatal(err)
	}
	if err := RegisterVerifier("_bar", defaultVerifier); err != nil {
		t.Fatal(err)
	}

	tests := map[string]struct {
		files   [][2]string
		vc      *VerificationConfig
		wantErr bool
		err     error
	}{
		"no_signature_verification_config": {
			[][2]string{{"/.signatures.json", `{"signatures": []}`}},
			nil,
			true, errors.New("verification key not provided"),
		},
		"no_signatures_file_no_keyid": {
			[][2]string{{"/.manifest", `{"revision": "quickbrownfaux"}`}},
			NewVerificationConfig(map[string]*KeyConfig{}, "", "", nil),
			false, nil,
		},
		"no_signatures_file": {
			[][2]string{{"/.manifest", `{"revision": "quickbrownfaux"}`}},
			NewVerificationConfig(map[string]*KeyConfig{}, "somekey", "", nil),
			true, errors.New("bundle missing .signatures.json file"),
		},
		"no_signatures": {
			[][2]string{{"/.signatures.json", `{"signatures": []}`}},
			NewVerificationConfig(map[string]*KeyConfig{}, "", "", nil),
			true, errors.New(".signatures.json: missing JWT (expected exactly one)"),
		},
		"digest_mismatch": {
			[][2]string{
				{"/.signatures.json", fmt.Sprintf(`{"signatures": ["%v"]}`, signedTokenHS256)},
				{"/a/b/c/data.json", "[1,2,3]"},
				{"/.manifest", `{"revision": "quickbrownfaux"}`},
			},
			NewVerificationConfig(map[string]*KeyConfig{"foo": {Key: "secret", Algorithm: "HS256"}}, "", "write", nil),
			true, errors.New("a/b/c/data.json: digest mismatch (want: 42cfe6768b57bb5f7503c165c28dd07ac5b813554ebc850f2cc35843e7137b1d, got: a615eeaee21de5179de080de8c3052c8da901138406ba71c38c032845f7d54f4)"),
		},
		"no_hashing_alg": {
			[][2]string{
				{"/.signatures.json", fmt.Sprintf(`{"signatures": ["%v"]}`, otherSignedTokenHS256)},
				{"/a/b/c/data.json", "[1,2,3]"},
			},
			NewVerificationConfig(map[string]*KeyConfig{"foo": {Key: "secret", Algorithm: "HS256"}}, "", "write", nil),
			true, errors.New("no hashing algorithm provided for file a/b/c/data.json"),
		},
		"exclude_files": {
			[][2]string{
				{"/.signatures.json", fmt.Sprintf(`{"signatures": ["%v"]}`, signedTokenHS256)},
				{"/.manifest", `{"revision": "quickbrownfaux"}`},
				{"/a/b/c/data.json", "[1,2,3]"},
				{"/http/policy/policy.rego", `package example`},
			},
			NewVerificationConfig(map[string]*KeyConfig{"foo": {Key: "secret", Algorithm: "HS256"}}, "", "write", []string{".*", "a/b/c/data.json", "http/policy/policy.rego"}),
			false, nil,
		},
		"customer_signer_verifier": {
			[][2]string{
				{"/.signatures.json", fmt.Sprintf(`{"signatures": ["%v"],"plugin":"_bar"}`, signedTokenHS256)},
				{"/.manifest", `{"revision": "quickbrownfaux"}`},
				{"/a/b/c/data.json", "[1,2,3]"},
				{"/http/policy/policy.rego", `package example`},
			},
			NewVerificationConfig(map[string]*KeyConfig{"foo": {Key: "secret", Algorithm: "HS256"}}, "", "write", []string{".*", "a/b/c/data.json", "http/policy/policy.rego"}),
			false, nil,
		},
	}

	for name, tc := range tests {
		t.Run(name, func(t *testing.T) {

			buf := archive.MustWriteTarGz(tc.files)
			reader := NewReader(buf).WithBundleVerificationConfig(tc.vc)
			_, err := reader.Read()

			if tc.wantErr {
				if err == nil {
					t.Fatal("Expected error but got nil")
				}

				if tc.err != nil && tc.err.Error() != err.Error() {
					t.Fatalf("Expected error message %v but got %v", tc.err.Error(), err.Error())
				}
			} else if err != nil {
				t.Fatalf("Unexpected error %v", err)
			}
		})
	}
}

func TestReadWithSignaturesWithBaseDir(t *testing.T) {
	signedTokenHS256 := `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyJ9.eyJmaWxlcyI6W3sibmFtZSI6ImZvby9iYXIvLm1hbmlmZXN0IiwiaGFzaCI6IjUwN2EyYzM4YTE0NDFkYjU4ZDJjYjg3OTgyYzQyYWE5MWE0MzQyZWY0MjJhNmI1NDJlZGRlYmVlZjZmMDQxMmYiLCJhbGdvcml0aG0iOiJTSEEtMjU2In0seyJuYW1lIjoiZm9vL2Jhci9hL2IvYy9kYXRhLmpzb24iLCJoYXNoIjoiYTYxNWVlYWVlMjFkZTUxNzlkZTA4MGRlOGMzMDUyYzhkYTkwMTEzODQwNmJhNzFjMzhjMDMyODQ1ZjdkNTRmNCIsImFsZ29yaXRobSI6IlNIQS0yNTYifSx7Im5hbWUiOiJmb28vYmFyL2h0dHAvcG9saWN5L3BvbGljeS5yZWdvIiwiaGFzaCI6ImY2NjQ0NjFlMzAzYjM3YzIwYzVlMGJlMjkwMDg4MTY3OGNkZjhlODYwYWE0MzNhNWExNGQ0OTRiYTNjNjY2NDkiLCJhbGdvcml0aG0iOiJTSEEtMjU2In1dLCJpYXQiOjE1OTIyNDgwMjcsImlzcyI6IkpXVFNlcnZpY2UiLCJzY29wZSI6IndyaXRlIn0.qTHkuBDVuT-Zl5pbJdZ6LoJ9eooFOhhpRdCheauDrlA`

	files := [][2]string{
		{"/.manifest", `{"revision": "quickbrownfaux"}`},
		{"/.signatures.json", fmt.Sprintf(`{"signatures": ["%v"]}`, signedTokenHS256)},
		{"/a/b/c/data.json", "[1,2,3]"},
		{"/http/policy/policy.rego", `package example`},
	}

	vc := NewVerificationConfig(map[string]*KeyConfig{"foo": {Key: "secret", Algorithm: "HS256"}}, "", "write", nil)

	buf := archive.MustWriteTarGz(files)

	loader := NewTarballLoaderWithBaseURL(buf, "/foo/bar")
	reader := NewCustomReader(loader).WithBaseDir("/foo/bar").WithBundleVerificationConfig(vc)
	_, err := reader.Read()
	if err != nil {
		t.Fatalf("Unexpected error %v", err)
	}
}

func TestReadWithPatch(t *testing.T) {
	files := [][2]string{
		{"/.manifest", `{"revision": "quickbrownfaux",  "roots": ["a"]}`},
		{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "a/b/c"}]}`},
	}

	buf := archive.MustWriteTarGz(files)

	loader := NewTarballLoaderWithBaseURL(buf, "/foo/bar")
	reader := NewCustomReader(loader).WithBaseDir("/foo/bar")
	b, err := reader.Read()
	if err != nil {
		t.Fatalf("Unexpected error %v", err)
	}

	actual := b.Type()
	if actual != DeltaBundleType {
		t.Fatalf("Expected delta bundle but got %v", actual)
	}

	if len(b.Patch.Data) != 2 {
		t.Fatalf("Expected two patch operations but got %v", len(b.Patch.Data))
	}

	p1 := PatchOperation{
		Op:    "add",
		Path:  "/a/b/d",
		Value: "foo",
	}

	p2 := PatchOperation{
		Op:   "remove",
		Path: "a/b/c",
	}

	expected := Patch{Data: []PatchOperation{p1, p2}}

	if !reflect.DeepEqual(b.Patch.Data, expected.Data) {
		t.Fatalf("Expected patch %v but got %v", expected.Data, b.Patch.Data)
	}
}

func TestReadWithPatchExtraFiles(t *testing.T) {
	cases := []struct {
		note  string
		files [][2]string
		err   string
	}{
		{
			note: "extra data file",
			files: [][2]string{
				{"/.manifest", `{"revision": "quickbrownfaux",  "roots": ["a"]}`},
				{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "a/b/c"}]}`},
				{"/a/b/c/data.json", "[1,2,3]"},
			},
			err: "delta bundle expected to contain only patch file but data files found",
		},
		{
			note: "extra policy file",
			files: [][2]string{
				{"/.manifest", `{"revision": "quickbrownfaux",  "roots": ["a"]}`},
				{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "a/b/c"}]}`},
				{"/http/policy/policy.rego", `package example`},
			},
			err: "delta bundle expected to contain only patch file but policy files found",
		},
		{
			note: "extra wasm file",
			files: [][2]string{
				{"/.manifest", `{"revision": "quickbrownfaux",  "roots": ["a"]}`},
				{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "a/b/c"}]}`},
				{"/policy.wasm", `modules-compiled-as-wasm-binary`},
			},
			err: "delta bundle expected to contain only patch file but wasm files found",
		},
	}

	for _, tc := range cases {
		t.Run(tc.note, func(t *testing.T) {
			buf := archive.MustWriteTarGz(tc.files)
			loader := NewTarballLoaderWithBaseURL(buf, "/foo/bar")
			reader := NewCustomReader(loader).WithBaseDir("/foo/bar")
			_, err := reader.Read()
			if tc.err == "" && err != nil {
				t.Fatal("Unexpected error occurred:", err)
			} else if tc.err != "" && err == nil {
				t.Fatal("Expected error but got success")
			} else if tc.err != "" && err != nil {
				if tc.err != err.Error() {
					t.Fatalf("Expected error to contain %q but got: %v", tc.err, err)
				}
			}
		})
	}

}

func TestReadWithPatchPersistProperty(t *testing.T) {
	cases := []struct {
		note    string
		files   [][2]string
		persist bool
		err     string
	}{
		{
			note: "persist true property",
			files: [][2]string{
				{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "a/b/c"}]}`},
			},
			persist: true,
			err:     "'persist' property is true in config. persisting delta bundle to disk is not supported",
		},
		{
			note: "persist false property",
			files: [][2]string{
				{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "a/b/c"}]}`},
			},
			persist: false,
			err:     "",
		},
	}

	for _, tc := range cases {
		t.Run(tc.note, func(t *testing.T) {
			buf := archive.MustWriteTarGz(tc.files)
			loader := NewTarballLoaderWithBaseURL(buf, "/foo/bar")
			reader := NewCustomReader(loader).
				WithBundlePersistence(tc.persist).WithBaseDir("/foo/bar")
			_, err := reader.Read()
			if tc.err == "" && err != nil {
				t.Fatal("Unexpected error occurred:", err)
			} else if tc.err != "" && err == nil {
				t.Fatal("Expected error but got success")
			} else if tc.err != "" && err != nil {
				if tc.err != err.Error() {
					t.Fatalf("Expected error to contain %q but got: %v", tc.err, err)
				}
			}
		})
	}
}

func TestReadWithSignaturesExtraFiles(t *testing.T) {
	signedTokenHS256 := `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyJ9.eyJmaWxlcyI6W3sibmFtZSI6Ii5tYW5pZmVzdCIsImhhc2giOiI1MDdhMmMzOGExNDQxZGI1OGQyY2I4Nzk4MmM0MmFhOTFhNDM0MmVmNDIyYTZiNTQyZWRkZWJlZWY2ZjA0MTJmIiwiYWxnb3JpdGhtIjoiU0hBLTI1NiJ9LHsibmFtZSI6ImEvYi9jL2RhdGEuanNvbiIsImhhc2giOiI0MmNmZTY3NjhiNTdiYjVmNzUwM2MxNjVjMjhkZDA3YWM1YjgxMzU1NGViYzg1MGYyY2MzNTg0M2U3MTM3YjFkIiwiYWxnb3JpdGhtIjoiU0hBLTI1NiJ9LHsibmFtZSI6Imh0dHAvcG9saWN5L3BvbGljeS5yZWdvIiwiaGFzaCI6ImE2MTVlZWFlZTIxZGU1MTc5ZGUwODBkZThjMzA1MmM4ZGE5MDExMzg0MDZiYTcxYzM4YzAzMjg0NWY3ZDU0ZjQiLCJhbGdvcml0aG0iOiJTSEEtMjU2In1dLCJpYXQiOjE1OTIyNDgwMjcsImlzcyI6IkpXVFNlcnZpY2UiLCJzY29wZSI6IndyaXRlIn0.Vmm9UDiInUnXXlk-OOjiCy3rR7EVvXS-OFst1rbh3Zo`

	files := [][2]string{
		{"/.manifest", `{"revision": "quickbrownfaux"}`},
		{"/.signatures.json", fmt.Sprintf(`{"signatures": ["%v"]}`, signedTokenHS256)},
	}

	vc := NewVerificationConfig(map[string]*KeyConfig{"foo": {Key: "secret", Algorithm: "HS256"}}, "", "write", nil)

	buf := archive.MustWriteTarGz(files)
	reader := NewReader(buf).WithBundleVerificationConfig(vc)
	_, err := reader.Read()
	if err == nil {
		t.Fatal("Expected error but got nil")
	}

	expected := []string{
		"file(s) [a/b/c/data.json http/policy/policy.rego] specified in bundle signatures but not found in the target bundle",
		"file(s) [http/policy/policy.rego a/b/c/data.json] specified in bundle signatures but not found in the target bundle",
	}

	var found bool
	if err.Error() == expected[0] || err.Error() == expected[1] {
		found = true
	}

	if !found {
		t.Fatalf("Expected error message to be one of %v but got %v", expected, err.Error())
	}
}

func TestVerifyBundleFileHash(t *testing.T) {
	// add files to the bundle and reader
	// compare the hash the for target files
	files := [][2]string{
		{"/.manifest", `{"revision": "quickbrownfaux"}`},
		{"/a/b/c/data.json", "[1,2,3]"},
		{"/a/b/d/data.json", "true"},
		{"/a/b/y/data.yaml", `foo: 1`},
		{"/example/example.rego", `package example`},
		{"/policy.wasm", `modules-compiled-as-wasm-binary`},
		{"/data.json", `{"x": {"y": true}, "a": {"b": {"z": true}}}`},
	}

	buf := archive.MustWriteTarGz(files)
	reader := NewReader(buf)
	reader.files = map[string]FileInfo{}

	expDigests := make([]string, len(files))
	expDigests[0] = "a005c38a509dc2d5a7407b9494efb2ad"
	expDigests[1] = "60f7b5dc86ded48785436192a08dbfd04894d7f1b417c4f8d3714679a7f78cb3c833f16a8559a1cf1f32968747dc1d95ef34826263dacf125ded8f5c374be4c0"
	expDigests[2] = "b326b5062b2f0e69046810717534cb09"
	expDigests[3] = "20f27a640a233e6524fe7d138898583cd43475724806feb26be7f214e1d10b29edf6a0d3cb08f82107a45686b61b8fdabab6406cf4e70efe134f42238dbd70ab"
	expDigests[4] = "ceecc199d432a4eeae305914ea4816cb"
	expDigests[5] = "4f73765168fd8b5c294b739436da312cc5e979faf09f67bf576d36ea79a4f79c70cbb3c33d06ff65f531a9f42abd0a8f4daacc554cb521837e876dc28f56ce89"
	expDigests[6] = "36669864a622563256817033b1fc53db"

	// populate the files on the reader
	// this simulates the files seen by the reader after
	// decoding the signatures in the "signatures.json" file
	for i, f := range files {
		file := FileInfo{
			Name: f[0],
			Hash: expDigests[i],
		}

		if i%2 == 0 {
			file.Algorithm = MD5.String()
		} else {
			file.Algorithm = SHA512.String()
		}

		reader.files[f[0]] = file
	}

	for _, f := range files {
		buf := bytes.NewBufferString(f[1])
		err := reader.verifyBundleFile(f[0], *buf)
		if err != nil {
			t.Fatal(err)
		}
	}

	// check there are no files left on the reader
	if len(reader.files) != 0 {
		t.Fatalf("Expected no files on the reader but got %v", len(reader.files))
	}
}

func TestIsFileExcluded(t *testing.T) {
	cases := []struct {
		note    string
		file    string
		pattern []string
		exp     bool
	}{
		{
			note:    "exact",
			file:    "data.json",
			pattern: []string{"data.json"},
			exp:     true,
		},
		{
			note:    "hidden",
			file:    ".manifest",
			pattern: []string{".*"},
			exp:     true,
		},
		{
			note:    "no_match",
			file:    "data.json",
			pattern: []string{".*"},
			exp:     false,
		},
		{
			note:    "dir_match",
			file:    "/a/b/data.json",
			pattern: []string{"/a/b/*"},
			exp:     true,
		},
		{
			note:    "dir_no_match",
			file:    "/a/b/c/data.json",
			pattern: []string{"/a/b/*"},
			exp:     false,
		},
	}

	for _, tc := range cases {
		t.Run(tc.note, func(t *testing.T) {

			buf := archive.MustWriteTarGz([][2]string{})
			vc := NewVerificationConfig(map[string]*KeyConfig{}, "", "", tc.pattern)
			reader := NewReader(buf).WithBundleVerificationConfig(vc)
			actual := reader.isFileExcluded(tc.file)

			if actual != tc.exp {
				t.Fatalf("Expected file exclude result for %v %v but got %v", tc.file, tc.exp, actual)
			}
		})
	}
}

func TestReadRootValidation(t *testing.T) {
	cases := []struct {
		note  string
		files [][2]string
		err   string
	}{
		{
			note: "default full extent",
			files: [][2]string{
				{"/.manifest", `{"revision": "abcd"}`},
				{"/data.json", `{"a": 1}`},
				{"/x.rego", `package foo`},
			},
			err: "",
		},
		{
			note: "explicit full extent",
			files: [][2]string{
				{"/.manifest", `{"revision": "abcd", "roots": [""]}`},
				{"/data.json", `{"a": 1}`},
				{"/x.rego", `package foo`},
			},
			err: "",
		},
		{
			note: "implicit prefixed",
			files: [][2]string{
				{"/.manifest", `{"revision": "abcd", "roots": ["a/b", "foo"]}`},
				{"/data.json", `{"a": {"b": 1}}`},
				{"/x.rego", `package foo.bar`},
			},
			err: "",
		},
		{
			note: "err empty",
			files: [][2]string{
				{"/.manifest", `{"revision": "abcd", "roots": []}`},
				{"/x.rego", `package foo`},
			},
			err: "manifest roots [] do not permit 'package foo' in module '/x.rego'",
		},
		{
			note: "err overlapped",
			files: [][2]string{
				{"/.manifest", `{"revision": "abcd", "roots": ["a/b", "a"]}`},
			},
			err: "manifest has overlapped roots: 'a/b' and 'a'",
		},
		{
			note: "edge overlapped partial segment",
			files: [][2]string{
				{"/.manifest", `{"revision": "abcd", "roots": ["a", "another_root"]}`},
			},
			err: "",
		},
		{
			note: "err package outside scope",
			files: [][2]string{
				{"/.manifest", `{"revision": "abcd", "roots": ["a", "b", "c/d"]}`},
				{"/a.rego", `package b.c`},
				{"/x.rego", `package c.e`},
			},
			err: "manifest roots [a b c/d] do not permit 'package c.e' in module '/x.rego'",
		},
		{
			note: "err data outside scope",
			files: [][2]string{
				{"/.manifest", `{"revision": "abcd", "roots": ["a", "b", "c/d"]}`},
				{"/data.json", `{"a": 1}`},
				{"/c/e/data.json", `"bad bad bad"`},
			},
			err: "manifest roots [a b c/d] do not permit data at path '/c/e'",
		},
		{
			note: "err data patch outside scope",
			files: [][2]string{
				{"/.manifest", `{"revision": "abcd", "roots": ["a", "b", "c/d"]}`},
				{"/patch.json", `{"data": [{"op": "add", "path": "/a/b/d", "value": "foo"}, {"op": "remove", "path": "/c/e"}]}`},
			},
			err: "manifest roots [a b c/d] do not permit data patch at path 'c/e'",
		},
	}

	for _, tc := range cases {
		t.Run(tc.note, func(t *testing.T) {
			buf := archive.MustWriteTarGz(tc.files)
			_, err := NewReader(buf).IncludeManifestInData(true).Read()
			if tc.err == "" && err != nil {
				t.Fatal("Unexpected error occurred:", err)
			} else if tc.err != "" && err == nil {
				t.Fatal("Expected error but got success")
			} else if tc.err != "" && err != nil {
				if !strings.Contains(err.Error(), tc.err) {
					t.Fatalf("Expected error to contain %q but got: %v", tc.err, err)
				}
			}
		})
	}
}

func TestRootPathsContain(t *testing.T) {
	tests := []struct {
		note  string
		roots []string
		path  string
		want  bool
	}{
		{
			note:  "empty contains empty",
			roots: []string{""},
			path:  "",
			want:  true,
		},
		{
			note:  "empty contains non-empty",
			roots: []string{""},
			path:  "foo/bar",
			want:  true,
		},
		{
			note:  "single prefix",
			roots: []string{"foo"},
			path:  "foo/bar",
			want:  true,
		},
		{
			note:  "single prefix no match",
			roots: []string{"bar"},
			path:  "foo/bar",
			want:  false,
		},
		{
			note:  "multiple prefix",
			roots: []string{"baz", "foo"},
			path:  "foo/bar",
			want:  true,
		},
		{
			note:  "multiple prefix no match",
			roots: []string{"baz", "qux"},
			path:  "foo/bar",
			want:  false,
		},
		{
			note:  "single exact",
			roots: []string{"foo/bar"},
			path:  "foo/bar",
			want:  true,
		},
		{
			note:  "single exact no match",
			roots: []string{"foo/ba"},
			path:  "foo/bar",
			want:  false,
		},
		{
			note:  "multiple exact",
			roots: []string{"baz/bar", "foo/bar"},
			path:  "foo/bar",
			want:  true,
		},
		{
			note:  "root too long",
			roots: []string{"foo/bar/"},
			path:  "foo/bar",
			want:  false,
		},
	}

	for _, tc := range tests {
		t.Run(tc.note, func(t *testing.T) {
			if RootPathsContain(tc.roots, tc.path) != tc.want {
				t.Fatalf("expected %v contains %v to be %v", tc.roots, tc.path, tc.want)
			}
		})
	}
}

func TestReadErrorBadGzip(t *testing.T) {
	buf := bytes.NewBufferString("bad gzip bytes")
	_, err := NewReader(buf).Read()
	if err == nil {
		t.Fatal("expected error")
	}
}

func TestReadErrorBadTar(t *testing.T) {
	var buf bytes.Buffer
	gw := gzip.NewWriter(&buf)
	_, _ = gw.Write([]byte("bad tar bytes"))
	_ = gw.Close()
	_, err := NewReader(&buf).Read()
	if err == nil {
		t.Fatal("expected error")
	}
}

func TestReadErrorBadContents(t *testing.T) {
	tests := []struct {
		files [][2]string
	}{
		{[][2]string{{"/test.rego", "lkafjasdkljf"}}},
		{[][2]string{{"/data.json", "lskjafkljsdf"}}},
		{[][2]string{{"/data.json", "[1,2,3]"}}},
		{[][2]string{
			{"/a/b/data.json", "[1,2,3]"},
			{"a/b/c/data.json", "true"},
		}},
		{[][2]string{{"/test.rego", ""}}},
		{[][2]string{
			{"/a/b/data.json", `{"c": "foo"}`},
			{"/data.json", `{"a": {"b": {"c": [123]}}}`},
		}},
	}
	for _, test := range tests {
		buf := archive.MustWriteTarGz(test.files)
		_, err := NewReader(buf).Read()
		if err == nil {
			t.Fatal("expected error")
		}
	}

}

func TestRoundtripDeprecatedWrite(t *testing.T) {

	bundle := Bundle{
		Data: map[string]any{
			"foo": map[string]any{
				"bar": []any{json.Number("1"), json.Number("2"), json.Number("3")},
				"baz": true,
				"qux": "hello",
			},
		},
		Modules: []ModuleFile{
			{
				URL:    "/foo/corge/corge.rego",
				Path:   "/foo/corge/corge.rego",
				Parsed: ast.MustParseModule(`package foo.corge`),
				Raw:    []byte(`package foo.corge`),
			},
		},
		WasmModules: []WasmModuleFile{
			{
				Path: "/policy.wasm",
				URL:  "/policy.wasm",
				Raw:  []byte("modules-compiled-as-wasm-binary"),
			},
		},
		Manifest: Manifest{
			Revision: "quickbrownfaux",
		},
	}

	var buf bytes.Buffer

	if err := Write(&buf, bundle); err != nil {
		t.Fatal("Unexpected error:", err)
	}

	bundle2, err := NewReader(&buf).Read()
	if err != nil {
		t.Fatal("Unexpected error:", err)
	}

	if !bundle2.Equal(bundle) {
		t.Fatalf("\nExp: %+v\nGot: %+v", bundle, bundle2)
	}

}

func TestRoundtrip(t *testing.T) {

	bundle := Bundle{
		Data: map[string]any{
			"foo": map[string]any{
				"bar": []any{json.Number("1"), json.Number("2"), json.Number("3")},
				"baz": true,
				"qux": "hello",
			},
		},
		Modules: []ModuleFile{
			{
				URL:    "/foo/corge/corge.rego",
				Path:   "/foo/corge/corge.rego",
				Parsed: ast.MustParseModule(`package foo.corge`),
				Raw:    []byte("package foo.corge\n"),
			},
		},
		WasmModules: []WasmModuleFile{
			{
				Path: "/policy.wasm",
				URL:  "/policy.wasm",
				Raw:  []byte("modules-compiled-as-wasm-binary"),
			},
		},
		Manifest: Manifest{
			Roots:    &[]string{""},
			Revision: "quickbrownfaux",
			Metadata: map[string]any{"version": "v1", "hello": "world"},
		},
	}

	if err := bundle.GenerateSignature(NewSigningConfig("secret", "HS256", ""), "foo", false); err != nil {
		t.Fatal("Unexpected error:", err)
	}

	var buf bytes.Buffer

	if err := NewWriter(&buf).Write(bundle); err != nil {
		t.Fatal("Unexpected error:", err)
	}

	vc := NewVerificationConfig(map[string]*KeyConfig{"foo": {Key: "secret", Algorithm: "HS256"}}, "foo", "", nil)

	bundle2, err := NewReader(&buf).WithBundleVerificationConfig(vc).Read()
	if err != nil {
		t.Fatal("Unexpected error:", err)
	}

	if !bundle2.Equal(bundle) {
		t.Fatal("Exp:", bundle, "\n\nGot:", bundle2)
	}

	if !reflect.DeepEqual(bundle2.Signatures, bundle.Signatures) {
		t.Fatal("Expected signatures to be same")
	}
}

func TestWriterRejectsMixedPlanManifestFormats(t *testing.T) {
	manifest := Manifest{Roots: &[]string{""}, Revision: "r"}

	t.Run("proto manifest with JSON plan", func(t *testing.T) {
		b := Bundle{
			Manifest: manifest,
			PlanModules: []PlanModuleFile{{
				Path: PlanFile, URL: PlanFile, Raw: []byte("{}"),
			}},
		}
		b.SetManifestProto(true)
		err := NewWriter(io.Discard).Write(b)
		if err == nil || !strings.Contains(err.Error(), "proto manifest but JSON plan") {
			t.Fatalf("expected mixed-format error, got: %v", err)
		}
	})

	t.Run("JSON manifest with proto plan", func(t *testing.T) {
		b := Bundle{
			Manifest: manifest,
			PlanModules: []PlanModuleFile{{
				Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x00},
			}},
		}
		err := NewWriter(io.Discard).Write(b)
		if err == nil || !strings.Contains(err.Error(), "JSON manifest but proto plan") {
			t.Fatalf("expected mixed-format error, got: %v", err)
		}
	})

	t.Run("empty manifest exempts proto plan", func(t *testing.T) {
		b := Bundle{
			Manifest: Manifest{},
			PlanModules: []PlanModuleFile{{
				Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x00},
			}},
		}
		if err := NewWriter(io.Discard).Write(b); err != nil {
			t.Fatalf("expected empty-manifest write to succeed, got: %v", err)
		}
	})

	t.Run("uniform proto succeeds", func(t *testing.T) {
		b := Bundle{
			Manifest: manifest,
			PlanModules: []PlanModuleFile{{
				Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x00},
			}},
		}
		b.SetManifestProto(true)
		if err := NewWriter(io.Discard).Write(b); err != nil {
			t.Fatalf("expected uniform-proto write to succeed, got: %v", err)
		}
	})
}

func TestRoundtripWithProtoManifestSigned(t *testing.T) {

	bundle := Bundle{
		Data: map[string]any{
			"foo": map[string]any{
				"bar": []any{json.Number("1"), json.Number("2"), json.Number("3")},
			},
		},
		PlanModules: []PlanModuleFile{
			{
				Path: PlanProtoFile,
				URL:  PlanProtoFile,
				Raw:  []byte("plan-proto-bytes"),
			},
		},
		Manifest: Manifest{
			Roots:    &[]string{""},
			Revision: "quickbrownfaux",
			Metadata: map[string]any{"version": "v1", "hello": "world"},
		},
	}
	bundle.SetManifestProto(true)

	if err := bundle.GenerateSignature(NewSigningConfig("secret", "HS256", ""), "foo", false); err != nil {
		t.Fatal("Unexpected error:", err)
	}

	var buf bytes.Buffer
	if err := NewWriter(&buf).Write(bundle); err != nil {
		t.Fatal("Unexpected error:", err)
	}

	vc := NewVerificationConfig(map[string]*KeyConfig{"foo": {Key: "secret", Algorithm: "HS256"}}, "foo", "", nil)
	bundle2, err := NewReader(&buf).WithBundleVerificationConfig(vc).Read()
	if err != nil {
		t.Fatalf("verify failed for proto-manifest bundle: %v", err)
	}

	if bundle2.Manifest.Revision != bundle.Manifest.Revision {
		t.Fatalf("manifest not preserved through proto round-trip: got revision %q", bundle2.Manifest.Revision)
	}
	if !reflect.DeepEqual(bundle2.Signatures, bundle.Signatures) {
		t.Fatal("expected signatures to be same after read")
	}
}

func TestReadProtoManifestPreservesFormatIntent(t *testing.T) {
	src := Bundle{
		Data: map[string]any{},
		Manifest: Manifest{
			Roots:    &[]string{""},
			Revision: "abc",
		},
	}
	src.SetManifestProto(true)

	var buf bytes.Buffer
	if err := NewWriter(&buf).Write(src); err != nil {
		t.Fatal(err)
	}

	loaded, err := NewReader(&buf).Read()
	if err != nil {
		t.Fatal(err)
	}
	if !loaded.manifestProto {
		t.Fatal("Reader did not propagate manifestProto onto the loaded bundle")
	}

	var out bytes.Buffer
	if err := NewWriter(&out).Write(loaded); err != nil {
		t.Fatal(err)
	}
	gr, err := gzip.NewReader(&out)
	if err != nil {
		t.Fatal(err)
	}
	tr := tar.NewReader(gr)
	var sawProto, sawJSON bool
	for {
		h, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			t.Fatal(err)
		}
		switch h.Name {
		case "/.manifest.pb":
			sawProto = true
		case "/.manifest":
			sawJSON = true
		}
	}
	if !sawProto {
		t.Error("re-written bundle missing /.manifest.pb")
	}
	if sawJSON {
		t.Error("re-written bundle silently downgraded to /.manifest")
	}
}

func TestRoundtripWithPlanModules(t *testing.T) {

	b := Bundle{
		Data: map[string]any{},
		PlanModules: []PlanModuleFile{
			{
				URL:  "/plan.json",
				Path: "/plan.json",
				Raw:  []byte(`{"foo": 7}`), // NOTE(tsandall): contents are ignored
			},
		},
	}

	var buf bytes.Buffer
	if err := Write(&buf, b); err != nil {
		t.Fatal(err)
	}

	b2, err := NewReader(&buf).Read()
	if err != nil {
		t.Fatal(err)
	}

	if len(b2.PlanModules) != 1 ||
		b2.PlanModules[0].Path != b.PlanModules[0].Path ||
		b2.PlanModules[0].URL != b.PlanModules[0].URL ||
		!bytes.Equal(b2.PlanModules[0].Raw, b.PlanModules[0].Raw) {
		t.Fatalf("expected %+v but got %+v", b, b2)
	}
}

func TestRoundtripDeltaBundle(t *testing.T) {

	// replace a value
	p1 := PatchOperation{
		Op:    "replace",
		Path:  "a/baz",
		Value: "bux",
	}

	// add a new object member
	p2 := PatchOperation{
		Op:    "add",
		Path:  "/a/foo",
		Value: []string{"hello", "world"},
	}

	bundle := Bundle{
		Patch: Patch{Data: []PatchOperation{p1, p2}},
		Manifest: Manifest{
			Revision: "delta",
			Roots:    &[]string{"a"},
		},
	}

	var buf bytes.Buffer

	if err := NewWriter(&buf).Write(bundle); err != nil {
		t.Fatal("Unexpected error:", err)
	}

	bundle2, err := NewReader(&buf).Read()
	if err != nil {
		t.Fatal("Unexpected error:", err)
	}

	if !bundle2.Equal(bundle) {
		t.Fatal("Exp:", bundle, "\n\nGot:", bundle2)
	}
}

func TestWriterUsePath(t *testing.T) {

	bundle := Bundle{
		Data: map[string]any{},
		Modules: []ModuleFile{
			{
				URL:    "/url.rego",
				Path:   "/path.rego",
				Parsed: ast.MustParseModule(`package x`),
				Raw:    []byte("package x\n"),
			},
		},
		Manifest: Manifest{Revision: "quickbrownfaux"},
	}

	var buf bytes.Buffer

	if err := NewWriter(&buf).UseModulePath(true).Write(bundle); err != nil {
		t.Fatal("Unexpected error:", err)
	}

	bundle2, err := NewReader(&buf).Read()
	if err != nil {
		t.Fatal("Unexpected error:", err)
	}

	if bundle2.Modules[0].URL != "/path.rego" || bundle2.Modules[0].Path != "/path.rego" {
		t.Fatal("expected module path to be used but got:", bundle2.Modules[0])
	}
}

func TestWriterSkipEmptyManifest(t *testing.T) {

	bundle := Bundle{
		Data:     map[string]any{},
		Manifest: Manifest{},
	}

	var buf bytes.Buffer

	if err := NewWriter(&buf).Write(bundle); err != nil {
		t.Fatal("Unexpected error:", err)
	}

	gr, err := gzip.NewReader(&buf)
	if err != nil {
		t.Fatal(err)
	}

	tr := tar.NewReader(gr)
	for {
		f, err := tr.Next()
		if err != nil {
			if err != io.EOF {
				t.Fatal(err)
			}
			break
		}

		if f.Name != "/data.json" {
			t.Fatal("expected only /data.json and /.manifest but got:", f.Name)
		}
	}
}

func TestGenerateSignature(t *testing.T) {
	signatures := SignaturesConfig{Signatures: []string{"some_token"}}

	bundle := Bundle{
		Data: map[string]any{
			"foo": map[string]any{
				"bar": []any{json.Number("1"), json.Number("2"), json.Number("3")},
				"baz": true,
				"qux": "hello",
			},
		},
		Modules: []ModuleFile{
			{
				URL:    "/foo/corge/corge.rego",
				Path:   "/foo/corge/corge.rego",
				Parsed: ast.MustParseModule(`package foo.corge`),
				Raw:    []byte("package foo.corge\n"),
			},
		},
		Wasm: []byte("modules-compiled-as-wasm-binary"),
		Manifest: Manifest{
			Revision: "quickbrownfaux",
		},
		Signatures: signatures,
	}

	sc := NewSigningConfig("secret", "HS256", "")

	err := bundle.GenerateSignature(sc, "", false)
	if err != nil {
		t.Fatal("Unexpected error:", err)
	}

	if reflect.DeepEqual(signatures, bundle.Signatures) {
		t.Fatal("Expected signatures to be different")
	}

	current := bundle.Signatures
	err = bundle.GenerateSignature(sc, "", false)
	if err != nil {
		t.Fatal("Unexpected error:", err)
	}

	if !reflect.DeepEqual(current, bundle.Signatures) {
		t.Fatal("Expected signatures to be same")
	}
}

func TestGenerateSignatureWithPlugin(t *testing.T) {
	signatures := SignaturesConfig{Signatures: []string{"some_token"}, Plugin: "_foo"}

	bundle := Bundle{
		Data: map[string]any{
			"foo": map[string]any{
				"bar": []any{json.Number("1"), json.Number("2"), json.Number("3")},
				"baz": true,
				"qux": "hello",
			},
		},
		Modules: []ModuleFile{
			{
				URL:    "/foo/corge/corge.rego",
				Path:   "/foo/corge/corge.rego",
				Parsed: ast.MustParseModule(`package foo.corge`),
				Raw:    []byte("package foo.corge\n"),
			},
		},
		Wasm: []byte("modules-compiled-as-wasm-binary"),
		Manifest: Manifest{
			Revision: "quickbrownfaux",
		},
		Signatures: signatures,
	}

	defaultSigner, _ := GetSigner(defaultSignerID)
	defaultVerifier, _ := GetVerifier(defaultVerifierID)
	if err := RegisterSigner("_foo", defaultSigner); err != nil {
		t.Fatal(err)
	}
	if err := RegisterVerifier("_foo", defaultVerifier); err != nil {
		t.Fatal(err)
	}
	sc := NewSigningConfig("secret", "HS256", "").WithPlugin("_foo")

	err := bundle.GenerateSignature(sc, "", false)
	if err != nil {
		t.Fatal("Unexpected error:", err)
	}

	if reflect.DeepEqual(signatures, bundle.Signatures) {
		t.Fatal("Expected signatures to be different")
	}

	current := bundle.Signatures
	err = bundle.GenerateSignature(sc, "", false)
	if err != nil {
		t.Fatal("Unexpected error:", err)
	}

	if !reflect.DeepEqual(current, bundle.Signatures) {
		t.Fatal("Expected signatures to be same")
	}
}

func TestFormatModulesRaw(t *testing.T) {

	bundle1 := Bundle{
		Modules: []ModuleFile{
			{
				URL:    "/foo/corge/corge.rego",
				Path:   "/foo/corge/corge.rego",
				Parsed: ast.MustParseModule(`package foo.corge`),
				Raw:    []byte("package foo.corge\n"),
			},
		},
	}

	bundle2 := Bundle{
		Modules: []ModuleFile{
			{
				URL:    "/foo/corge/corge.rego",
				Path:   "/foo/corge/corge.rego",
				Parsed: ast.MustParseModule(`package foo.corge`),
				Raw:    []byte("package foo.corge"),
			},
		},
	}

	tests := map[string]struct {
		bundle Bundle
		exp    bool
	}{
		"equal":     {bundle: bundle1, exp: true},
		"not_equal": {bundle: bundle2, exp: false},
	}

	for name, tc := range tests {
		t.Run(name, func(t *testing.T) {

			orig := tc.bundle.Modules[0].Raw
			err := tc.bundle.FormatModules(false)
			if err != nil {
				t.Fatal("Unexpected error:", err)
			}

			actual := bytes.Equal(orig, tc.bundle.Modules[0].Raw)
			if actual != tc.exp {
				t.Fatalf("Expected result %v but got %v", tc.exp, actual)
			}
		})
	}
}

func TestFormatModulesParsed(t *testing.T) {

	bundle := Bundle{
		Modules: []ModuleFile{
			{
				URL:    "/foo/corge/corge.rego",
				Path:   "/foo/corge/corge.rego",
				Parsed: ast.MustParseModule(`package foo.corge`),
				Raw:    nil,
			},
		},
	}

	tests := map[string]struct {
		bundle Bundle
	}{
		"parsed": {bundle: bundle},
	}

	for name, tc := range tests {
		t.Run(name, func(t *testing.T) {

			err := tc.bundle.FormatModules(false)
			if err != nil {
				t.Fatal("Unexpected error:", err)
			}

			exp := []byte("package foo.corge\n")
			if !bytes.Equal(tc.bundle.Modules[0].Raw, exp) {
				t.Fatalf("Expected raw policy %v but got %v", exp, tc.bundle.Modules[0].Raw)
			}
		})
	}
}

func TestHashBundleFiles(t *testing.T) {
	h, _ := NewSignatureHasher(SHA256)

	tests := map[string]struct {
		data     map[string]any
		manifest Manifest
		wasm     []byte
		plan     []byte
		exp      int
	}{
		"no_content":                 {map[string]any{}, Manifest{}, nil, nil, 1},
		"data":                       {map[string]any{"foo": "bar"}, Manifest{}, nil, nil, 1},
		"data_and_manifest":          {map[string]any{"foo": "bar"}, Manifest{Revision: "quickbrownfaux"}, []byte{}, nil, 2},
		"data_and_manifest_and_wasm": {map[string]any{"foo": "bar"}, Manifest{Revision: "quickbrownfaux"}, []byte("modules-compiled-as-wasm-binary"), nil, 3},
		"data_and_plan":              {map[string]any{"foo": "bar"}, Manifest{Revision: "quickbrownfaux"}, nil, []byte("not a plan but good enough"), 3},
	}

	for name, tc := range tests {
		t.Run(name, func(t *testing.T) {

			var plans []PlanModuleFile
			if len(tc.plan) > 0 {
				plans = append(plans, PlanModuleFile{
					URL:  "/plan.json",
					Path: "/plan.json",
					Raw:  tc.plan,
				})
			}

			f, err := hashBundleFiles(h, &Bundle{Data: tc.data, Manifest: tc.manifest, Wasm: tc.wasm, PlanModules: plans})
			if err != nil {
				t.Fatal("Unexpected error:", err)
			}

			if len(f) != tc.exp {
				t.Fatalf("Expected %v file(s) to be added to the signature but got %v", tc.exp, len(f))
			}
		})
	}
}

func TestWriterUseURL(t *testing.T) {

	bundle := Bundle{
		Data: map[string]any{},
		Modules: []ModuleFile{
			{
				URL:    "/url.rego",
				Path:   "/path.rego",
				Parsed: ast.MustParseModule(`package x`),
				Raw:    []byte("package x\n"),
			},
		},
		Manifest: Manifest{Revision: "quickbrownfaux"},
	}

	var buf bytes.Buffer

	if err := NewWriter(&buf).UseModulePath(false).Write(bundle); err != nil {
		t.Fatal("Unexpected error:", err)
	}

	bundle2, err := NewReader(&buf).Read()
	if err != nil {
		t.Fatal("Unexpected error:", err)
	}

	if bundle2.Modules[0].URL != "/url.rego" || bundle2.Modules[0].Path != "/url.rego" {
		t.Fatal("expected module path to be used but got:", bundle2.Modules[0])
	}
}

func TestRootPathsOverlap(t *testing.T) {
	cases := []struct {
		note     string
		rootA    string
		rootB    string
		expected bool
	}{
		{"both empty", "", "", true},
		{"a empty", "", "foo/bar", true},
		{"b empty", "foo/bar", "", true},
		{"no overlap", "a/b/c", "x/y", false},
		{"partial segment overlap a", "a/b", "a/banana", false},
		{"partial segment overlap b", "a/banana", "a/b", false},
		{"overlap a", "a/b", "a/b/c", true},
		{"overlap b", "a/b/c", "a/b", true},
	}

	for _, tc := range cases {
		t.Run(tc.note, func(t *testing.T) {
			actual := RootPathsOverlap(tc.rootA, tc.rootB)
			if actual != tc.expected {
				t.Errorf("Expected %t, got %t", tc.expected, actual)
			}
		})
	}
}

func TestParsedModules(t *testing.T) {
	cases := []struct {
		note            string
		bundle          Bundle
		name            string
		expectedModules []string
	}{
		{
			note: "base",
			bundle: Bundle{
				Modules: []ModuleFile{
					{
						Path:   "/foo/policy.rego",
						Parsed: ast.MustParseModule(`package foo`),
						Raw:    []byte(`package foo`),
					},
				},
			},
			name: "test-bundle",
			expectedModules: []string{
				"test-bundle/foo/policy.rego",
			},
		},
		{
			note: "filepath name",
			bundle: Bundle{
				Modules: []ModuleFile{
					{
						Path:   "/foo/policy.rego",
						Parsed: ast.MustParseModule(`package foo`),
						Raw:    []byte(`package foo`),
					},
				},
			},
			name: "/some/system/path",
			expectedModules: []string{
				"/some/system/path/foo/policy.rego",
			},
		},
		{
			note: "file url name",
			bundle: Bundle{
				Modules: []ModuleFile{
					{
						Path:   "/foo/policy.rego",
						Parsed: ast.MustParseModule(`package foo`),
						Raw:    []byte(`package foo`),
					},
				},
			},
			name: "file:///some/system/path",
			expectedModules: []string{
				"/some/system/path/foo/policy.rego",
			},
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			parsedMods := tc.bundle.ParsedModules(tc.name)

			for _, exp := range tc.expectedModules {
				mod, ok := parsedMods[exp]
				if !ok {
					t.Fatalf("Missing expected module %s, got: %+v", exp, parsedMods)
				}
				if mod == nil {
					t.Fatal("Expected module to be non-nil")
				}
			}
		})
	}
}

func TestMergeCorruptManifest(t *testing.T) {
	_, err := Merge([]*Bundle{
		{},
		{},
	})
	if err == nil || err.Error() != "bundle manifest not initialized" {
		t.Fatal("unexpected error:", err)
	}
}

func TestMergeRejectsMixedFormats(t *testing.T) {
	rootEmpty := &[]string{""}

	t.Run("mixed plan formats", func(t *testing.T) {
		jsonBundle := &Bundle{
			Manifest: Manifest{Roots: rootEmpty},
			PlanModules: []PlanModuleFile{{
				Path: PlanFile, URL: PlanFile, Raw: []byte("{}"),
			}},
		}
		protoBundle := &Bundle{
			Manifest: Manifest{Roots: rootEmpty},
			PlanModules: []PlanModuleFile{{
				Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x00},
			}},
		}
		_, err := Merge([]*Bundle{jsonBundle, protoBundle})
		if err == nil {
			t.Fatal("expected error for mixed plan formats, got nil")
		}
		if !strings.Contains(err.Error(), "mixed plan formats") {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("mixed manifest formats", func(t *testing.T) {
		jsonManifestBundle := &Bundle{
			Manifest: Manifest{Roots: rootEmpty, Revision: "j"},
		}
		protoManifestBundle := &Bundle{
			Manifest: Manifest{Roots: rootEmpty, Revision: "p"},
		}
		protoManifestBundle.SetManifestProto(true)

		_, err := Merge([]*Bundle{jsonManifestBundle, protoManifestBundle})
		if err == nil {
			t.Fatal("expected error for mixed manifest formats, got nil")
		}
		if !strings.Contains(err.Error(), "mixed manifest formats") {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("uniform proto inputs propagate flag", func(t *testing.T) {
		a := &Bundle{
			Manifest: Manifest{Roots: &[]string{"a"}, Revision: "a"},
			PlanModules: []PlanModuleFile{{
				Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x00},
			}},
		}
		a.SetManifestProto(true)
		b := &Bundle{
			Manifest: Manifest{Roots: &[]string{"b"}, Revision: "b"},
			PlanModules: []PlanModuleFile{{
				Path: PlanProtoFile, URL: PlanProtoFile, Raw: []byte{0x01},
			}},
		}
		b.SetManifestProto(true)

		merged, err := Merge([]*Bundle{a, b})
		if err != nil {
			t.Fatalf("expected uniform-format merge to succeed, got %v", err)
		}
		if !merged.manifestProto {
			t.Fatal("merged bundle should carry manifestProto=true forward from inputs")
		}
	})
}

func TestMerge(t *testing.T) {

	expRegoVersion := ast.DefaultRegoVersion.Int()

	cases := []struct {
		note       string
		bundles    []*Bundle
		wantBundle *Bundle
		wantErr    error
	}{
		{
			note:    "empty list",
			wantErr: errors.New("expected at least one bundle"),
		},
		{
			note: "no op",
			bundles: []*Bundle{
				{
					Manifest: Manifest{
						Revision: "abcdef",
					},
					Modules: []ModuleFile{
						{
							Path:   "x.rego",
							Parsed: ast.MustParseModule(`package foo`),
							Raw:    []byte("package foo"),
						},
					},
				},
			},
			wantBundle: &Bundle{
				Manifest: Manifest{
					Revision:         "abcdef",
					Roots:            &[]string{""},
					RegoVersion:      &expRegoVersion,
					FileRegoVersions: map[string]int{},
				},
				Modules: []ModuleFile{
					{
						Path:   "x.rego",
						Parsed: ast.MustParseModule(`package foo`),
						Raw:    []byte("package foo"),
					},
				},
			},
		},
		{
			note: "wasm merge legacy error",
			bundles: []*Bundle{
				{
					Manifest: Manifest{
						Roots: &[]string{
							"foo",
						},
					},
					Wasm: []byte("not really wasm, but good enough"),
				},
				{
					Manifest: Manifest{
						Roots: &[]string{
							"bar",
						},
					},
					Wasm: []byte("not really wasm, but good enough"),
				},
			},
			wantBundle: &Bundle{
				Manifest: Manifest{
					Roots: &[]string{
						"foo",
						"bar",
					},
					RegoVersion: &expRegoVersion,
				},
				Data: map[string]any{},
			},
		},
		{
			note: "wasm merge ok",
			bundles: []*Bundle{
				{
					Manifest: Manifest{
						Roots: &[]string{
							"logs",
						},
					},
					WasmModules: []WasmModuleFile{
						{
							URL:         "logs/mask/policy.wasm",
							Path:        "logs/mask/policy.wasm",
							Entrypoints: []ast.Ref{ast.MustParseRef("system.log.mask")},
							Raw:         []byte("not really wasm, but good enough"),
						},
					},
				},
				{
					Manifest: Manifest{
						Roots: &[]string{
							"authz",
						},
					},
					WasmModules: []WasmModuleFile{
						{
							URL:         "authz/allow/policy.wasm",
							Path:        "authz/allow/policy.wasm",
							Entrypoints: []ast.Ref{ast.MustParseRef("authz.allow")},
							Raw:         []byte("not really wasm, but good enough"),
						},
					},
				},
			},
			wantBundle: &Bundle{
				Manifest: Manifest{
					Roots: &[]string{
						"logs",
						"authz",
					},
					RegoVersion: &expRegoVersion,
				},
				WasmModules: []WasmModuleFile{
					{
						URL:         "logs/mask/policy.wasm",
						Path:        "logs/mask/policy.wasm",
						Entrypoints: []ast.Ref{ast.MustParseRef("system.log.mask")},
						Raw:         []byte("not really wasm, but good enough"),
					},
					{
						URL:         "authz/allow/policy.wasm",
						Path:        "authz/allow/policy.wasm",
						Entrypoints: []ast.Ref{ast.MustParseRef("authz.allow")},
						Raw:         []byte("not really wasm, but good enough"),
					},
				},
				Data: map[string]any{},
			},
		},
		{
			note: "merge policy",
			bundles: []*Bundle{
				{
					Manifest: Manifest{
						Roots: &[]string{
							"foo",
						},
					},
					Modules: []ModuleFile{
						{
							URL:    "foo/bar.rego",
							Parsed: ast.MustParseModule(`package foo`),
							Raw:    []byte("package foo"),
						},
					},
				},
				{
					Manifest: Manifest{
						Roots: &[]string{
							"baz",
						},
					},
					Modules: []ModuleFile{
						{
							URL:    "baz/qux.rego",
							Parsed: ast.MustParseModule(`package baz`),
							Raw:    []byte("package baz"),
						},
					},
				},
			},
			wantBundle: &Bundle{
				Manifest: Manifest{
					Roots: &[]string{
						"foo",
						"baz",
					},
					RegoVersion: &expRegoVersion,
				},
				Modules: []ModuleFile{
					{
						URL:    "foo/bar.rego",
						Parsed: ast.MustParseModule(`package foo`),
						Raw:    []byte("package foo"),
					},
					{
						URL:    "baz/qux.rego",
						Parsed: ast.MustParseModule(`package baz`),
						Raw:    []byte("package baz"),
					},
				},
				Data: map[string]any{},
			},
		},
		{
			note: "merge data",
			bundles: []*Bundle{
				{
					Manifest: Manifest{
						Roots: &[]string{
							"foo/bar",
						},
					},
					Data: map[string]any{
						"foo": map[string]any{
							"bar": "val1",
						},
					},
				},
				{
					Manifest: Manifest{
						Roots: &[]string{
							"baz",
						},
					},
					Data: map[string]any{
						"baz": "val2",
					},
				},
			},
			wantBundle: &Bundle{
				Manifest: Manifest{
					Roots: &[]string{
						"foo/bar",
						"baz",
					},
					RegoVersion: &expRegoVersion,
				},
				Data: map[string]any{
					"foo": map[string]any{
						"bar": "val1",
					},
					"baz": "val2",
				},
			},
		},
		{
			note: "merge empty data",
			bundles: []*Bundle{
				{
					Manifest: Manifest{
						Roots: &[]string{
							"foo/bar",
						},
					},
					Data: map[string]any{},
				},
				{
					Manifest: Manifest{
						Roots: &[]string{
							"baz",
						},
					},
					Data: map[string]any{},
				},
			},
			wantBundle: &Bundle{
				Manifest: Manifest{
					Roots: &[]string{
						"foo/bar",
						"baz",
					},
					RegoVersion: &expRegoVersion,
				},
				Data: map[string]any{},
			},
		},
		{
			note: "merge plans",
			bundles: []*Bundle{
				{
					Manifest: Manifest{
						Roots: &[]string{"a"},
					},
					PlanModules: []PlanModuleFile{
						{
							URL:  "a/plan.json",
							Path: "a/plan.json",
							Raw:  []byte("not a real plan but good enough"),
						},
					},
				},
				{
					Manifest: Manifest{
						Roots: &[]string{"b"},
					},
					PlanModules: []PlanModuleFile{
						{
							URL:  "b/plan.json",
							Path: "b/plan.json",
							Raw:  []byte("not a real plan but good enough"),
						},
					},
				},
			},
			wantBundle: &Bundle{
				Data: map[string]any{},
				Manifest: Manifest{
					Roots:       &[]string{"a", "b"},
					RegoVersion: &expRegoVersion,
				},
				PlanModules: []PlanModuleFile{
					{
						URL:  "a/plan.json",
						Path: "a/plan.json",
						Raw:  []byte("not a real plan but good enough"),
					},
					{
						URL:  "b/plan.json",
						Path: "b/plan.json",
						Raw:  []byte("not a real plan but good enough"),
					},
				},
			},
		},
		{
			note: "conflicting roots",
			bundles: []*Bundle{
				{
					Manifest: Manifest{
						Roots: &[]string{
							"foo/bar",
						},
					},
				},
				{
					Manifest: Manifest{
						Roots: &[]string{
							"foo",
						},
					},
				},
			},
			wantErr: errors.New("manifest has overlapped roots: 'foo/bar' and 'foo'"),
		},
	}

	for _, tc := range cases {
		t.Run(tc.note, func(t *testing.T) {
			for i := range tc.bundles {
				if err := tc.bundles[i].Manifest.validateAndInjectDefaults(tc.bundles[i]); err != nil {
					panic(err)
				}
			}
			b, err := Merge(tc.bundles)
			if tc.wantErr != nil {
				if err == nil {
					t.Fatal("expected error")
				} else if err.Error() != tc.wantErr.Error() {
					t.Fatalf("expected error %q but got: %q", tc.wantErr, err)
				}
			} else if err != nil {
				t.Fatal("unexpected error:", err)
			} else if !b.Equal(*tc.wantBundle) {
				t.Fatalf("Expected:\n\n%v\n\nGot:\n\n%v", tc.wantBundle, b)
			} else if !reflect.DeepEqual(b.Manifest, tc.wantBundle.Manifest) {
				t.Fatalf("Expected manifest:\n\n%v\n\nGot manifest:\n\n%v", tc.wantBundle.Manifest, b.Manifest)
			}
		})
	}
}
