diff --git a/Package.swift b/Package.swift index 280bfa8a..bb1f5118 100644 --- a/Package.swift +++ b/Package.swift @@ -49,6 +49,10 @@ let package = Package( .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])), ] ), + .target( + name: "TestRunner", + dependencies: ["AST", "IR", "Rego"] + ), // Internal module tests .testTarget( name: "ASTTests", @@ -68,6 +72,11 @@ let package = Package( dependencies: ["Rego"], resources: [.copy("TestData")] ), + .testTarget( + name: "TestRunnerTests", + dependencies: ["TestRunner", "AST", "IR", "Rego"], + resources: [.copy("Fixtures")] + ), // Public API surface tests .testTarget( name: "SwiftOPATests", @@ -77,6 +86,7 @@ let package = Package( name: "CLI", dependencies: [ "Rego", + "TestRunner", .product(name: "ArgumentParser", package: "swift-argument-parser"), ] ), diff --git a/Sources/CLI/CLI.swift b/Sources/CLI/CLI.swift index 0aaffc32..3858026a 100644 --- a/Sources/CLI/CLI.swift +++ b/Sources/CLI/CLI.swift @@ -5,6 +5,6 @@ struct CLIRootCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "swift-opa-cli", abstract: "An example command line showing swift-opa in action.", - subcommands: [EvalCommand.self, BenchCommand.self, CapabilitiesCommand.self] + subcommands: [EvalCommand.self, BenchCommand.self, CapabilitiesCommand.self, TestCommand.self] ) } diff --git a/Sources/CLI/TestCommand.swift b/Sources/CLI/TestCommand.swift new file mode 100644 index 00000000..301a355f --- /dev/null +++ b/Sources/CLI/TestCommand.swift @@ -0,0 +1,146 @@ +import ArgumentParser +import Foundation +import TestRunner + +/// `swift-opa-cli test` — runs `test_*` rules found in compiled IR plan bundles. +/// +/// Behaves like `opa test`, with the important caveat that swift-opa runs +/// *compiled* IR plans rather than `.rego` source. Test rules must therefore be +/// present in the plan bundle as funcs. OPA includes a test rule when it is +/// reachable from the build's entrypoints. This happens when the test +/// references an entrypoint's rules/data, or when the test's package/rules are +/// entrypoints themselves. Making the test package an entrypoint +/// (`opa build -b -t plan -e `) reliably includes all of its +/// tests, but is not the only way they can end up in the plan. +struct TestCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "test", + abstract: "Execute Rego test cases from compiled IR plan bundles.", + discussion: """ + Searches the given plan bundle paths for rules named test_* (run) and \ + todo_test_* (skipped), generates a plan for each, and evaluates it. + + Note: swift-opa runs compiled IR plans, not .rego source. Test rules \ + must be present in the plan as funcs. They are included when reachable \ + from the build's entrypoints; making the test package an entrypoint \ + (`opa build -b -t plan -e `) reliably includes all \ + of its tests. + """ + ) + + @Argument(help: "Bundle paths (directories) to search for tests.") + var paths: [String] = [] + + // MARK: CLI Options + + @Flag(name: [.customShort("b"), .customLong("bundle")], help: "Load paths as bundles (always on).") + var bundle: Bool = false + + @Option(name: [.customShort("r"), .customLong("run")], help: "Run only tests matching this regular expression.") + var run: String? + + @Flag(name: [.short, .long], help: "Verbose output (list every test).") + var verbose: Bool = false + + @Option(name: [.long], help: "Number of times to repeat each test.") + var count: Int = 1 + + @Flag( + name: [.customShort("z"), .customLong("exit-zero-on-skipped")], + help: "Exit with status 0 even when tests are skipped.") + var exitZeroOnSkipped: Bool = false + + // MARK: Unimplemented Option Stubs + + @Flag(name: [.long], help: .hidden) var bench: Bool = false + @Flag(name: [.long], help: .hidden) var benchmem: Bool = false + @Option(name: [.long], help: .hidden) var capabilities: String? + @Flag(name: [.customShort("c"), .customLong("coverage")], help: .hidden) var coverage: Bool = false + @Option(name: [.long], help: .hidden) var explain: String? + @Option(name: [.long], help: .hidden) var format: String? + @Option(name: [.long], help: .hidden) var ignore: [String] = [] + @Option(name: [.long], help: .hidden) var maxErrors: Int? + @Option(name: [.short, .long], help: .hidden) var parallel: Int? + @Option(name: [.long], help: .hidden) var schema: String? + @Flag(name: [.long], help: .hidden) var sort: Bool = false + @Option(name: [.customShort("t"), .customLong("target")], help: .hidden) var target: String? + @Option(name: [.long], help: .hidden) var threshold: Double? + @Option(name: [.long], help: .hidden) var timeout: String? + @Flag(name: [.long], help: .hidden) var v0Compatible: Bool = false + @Option(name: [.long], help: .hidden) var varValues: String? + @Flag(name: [.long], help: .hidden) var watch: Bool = false + + mutating func run() async throws { + warnUnimplemented() + + guard !paths.isEmpty else { + throw ValidationError("no bundle paths provided") + } + + // Resolve symlinks so the loaded paths match the enumerator's resolved + // child paths (e.g. /tmp -> /private/tmp on macOS). `URL(fileURLWithPath:)` + // already resolves relative paths against the current working directory. + let urls = paths.map { URL(fileURLWithPath: $0).resolvingSymlinksInPath() } + + let results = try await TestRunner.run(paths: urls, runFilter: run, count: count) + + if results.isEmpty { + FileHandle.standardError.write(Data("warning: no tests found under the given paths\n".utf8)) + } + + let report = TestReporter(verbose: verbose).render(results) + print(report) + + throw exitCode(for: results) + } + + /// Determines the process exit code, mirroring `opa test`: failures/errors + /// yield status 2, as do skipped tests, unless `--exit-zero-on-skipped` is set. + private func exitCode(for results: [TestResult]) -> ExitCode { + var hasFailure = false + var hasSkip = false + for result in results { + switch result.outcome { + case .failed, .errored: + hasFailure = true + case .skipped: + hasSkip = true + case .passed: + break + } + } + if hasFailure { + return ExitCode(2) + } + if hasSkip && !exitZeroOnSkipped { + return ExitCode(2) + } + return ExitCode.success + } + + /// Warnings on stderr for any unimplemented flags. + private func warnUnimplemented() { + var unimplemented: [String] = [] + if bench { unimplemented.append("--bench") } + if benchmem { unimplemented.append("--benchmem") } + if capabilities != nil { unimplemented.append("--capabilities") } + if coverage { unimplemented.append("--coverage") } + if explain != nil { unimplemented.append("--explain") } + if format != nil { unimplemented.append("--format") } + if !ignore.isEmpty { unimplemented.append("--ignore") } + if maxErrors != nil { unimplemented.append("--max-errors") } + if parallel != nil { unimplemented.append("--parallel") } + if schema != nil { unimplemented.append("--schema") } + if sort { unimplemented.append("--sort") } + if target != nil { unimplemented.append("--target") } + if threshold != nil { unimplemented.append("--threshold") } + if timeout != nil { unimplemented.append("--timeout") } + if v0Compatible { unimplemented.append("--v0-compatible") } + if varValues != nil { unimplemented.append("--var-values") } + if watch { unimplemented.append("--watch") } + + for flag in unimplemented { + FileHandle.standardError.write(Data("option `\(flag)` is not implemented.\n".utf8)) + } + } +} diff --git a/Sources/CLI/TestReporter.swift b/Sources/CLI/TestReporter.swift new file mode 100644 index 00000000..ac2d81c8 --- /dev/null +++ b/Sources/CLI/TestReporter.swift @@ -0,0 +1,64 @@ +import Foundation +import TestRunner + +/// Formats ``TestResult`` collections in a style close to `opa test`'s pretty output. +/// +/// Output lists only non-passing tests (failures, errors, skips) by default, +/// followed by a summary. Verbose output lists every test. +/// +/// We do not support printing failure traces, so the FAILURES section is omitted. +struct TestReporter { + let verbose: Bool + + private static let separator = String(repeating: "-", count: 80) + + /// Renders the report body (per-test lines + separator + summary). + func render(_ results: [TestResult]) -> String { + var lines: [String] = [] + + for result in results { + guard verbose || result.outcome != .passed else { + continue + } + if let file = result.testCase.file, let row = result.testCase.row { + lines.append("\(file):\(row):") + } + lines.append(statusLine(result)) + } + + lines.append(Self.separator) + lines.append(contentsOf: summary(results)) + return lines.joined(separator: "\n") + } + + private func statusLine(_ result: TestResult) -> String { + let duration = result.duration.formatted(.adaptive) + switch result.outcome { + case .passed: + return "\(result.testCase.name): PASS (\(duration))" + case .failed: + return "\(result.testCase.name): FAIL (\(duration))" + case .skipped: + return "\(result.testCase.name): SKIPPED" + case .errored(let message): + return "\(result.testCase.name): ERROR (\(duration))\n \(message)" + } + } + + private func summary(_ results: [TestResult]) -> [String] { + let total = results.count + let passed = results.count(where: { $0.outcome == .passed }) + let failed = results.count(where: { $0.outcome == .failed }) + let skipped = results.count(where: { $0.outcome == .skipped }) + let errored = results.count(where: { + guard case .errored = $0.outcome else { return false } + return true + }) + + var lines = ["PASS: \(passed)/\(total)"] + if failed > 0 { lines.append("FAIL: \(failed)/\(total)") } + if errored > 0 { lines.append("ERROR: \(errored)/\(total)") } + if skipped > 0 { lines.append("SKIPPED: \(skipped)/\(total)") } + return lines + } +} diff --git a/Sources/Rego/BundleLoader.swift b/Sources/Rego/BundleLoader.swift index 39dba319..2b1f0ddd 100644 --- a/Sources/Rego/BundleLoader.swift +++ b/Sources/Rego/BundleLoader.swift @@ -1,7 +1,7 @@ import AST import Foundation -struct BundleLoader { +package struct BundleLoader { var bundleFiles: any Sequence> init(fromFileSequence files: any Sequence>) { @@ -84,14 +84,14 @@ struct BundleLoader { return bundle } - public static func load(fromDirectory url: URL) throws -> OPA.Bundle { + package static func load(fromDirectory url: URL) throws -> OPA.Bundle { let files = DirectoryLoader(baseURL: url) return try BundleLoader(fromFileSequence: files).load() } // Accept either a directory to load a bundle from or a path to an individual file // which will be treated as a bundle tarball. - public static func load(fromFile url: URL) throws -> OPA.Bundle { + package static func load(fromFile url: URL) throws -> OPA.Bundle { let isDir = (try url.resourceValues(forKeys: [.isDirectoryKey])).isDirectory ?? false if isDir { return try load(fromDirectory: url) diff --git a/Sources/TestRunner/TestCase.swift b/Sources/TestRunner/TestCase.swift new file mode 100644 index 00000000..c9902119 --- /dev/null +++ b/Sources/TestRunner/TestCase.swift @@ -0,0 +1,62 @@ +import Foundation + +/// A single Rego test case found in a compiled IR policy. +/// +/// Test cases are Rego rules whose names begin with `test_`. Rules +/// beginning with `todo_test_` are found but marked ``skipped`` and never +/// evaluated. +public struct TestCase: Hashable, Sendable { + /// Fully-qualified query name, e.g. `data.authz_test.test_post_allowed`. + public let name: String + /// IR plan name used to invoke the generated wrapper, e.g. `authz_test/test_post_allowed`. + public let planName: String + /// The IR func name that implements the test, e.g. `g0.data.authz_test.test_post_allowed`. + public let funcName: String + /// Whether this test is skipped (a `todo_test_` rule). + public let skipped: Bool + /// Source file name the test was compiled from, when available (for verbose output). + public let file: String? + /// Source row the test was compiled from, when available (for verbose output). + public let row: Int? + + public init( + name: String, + planName: String, + funcName: String, + skipped: Bool, + file: String? = nil, + row: Int? = nil + ) { + self.name = name + self.planName = planName + self.funcName = funcName + self.skipped = skipped + self.file = file + self.row = row + } +} + +/// The outcome of running (or skipping) a single ``TestCase``. +public enum TestOutcome: Hashable, Sendable { + /// The test rule result was defined and its result was `true`. + case passed + /// The test rule result was undefined. + case failed + /// The test was a `todo_test_` rule and was not evaluated. + case skipped + /// Evaluating the test threw an error. + case errored(String) +} + +/// The result of a single test execution. +public struct TestResult: Sendable { + public let testCase: TestCase + public let outcome: TestOutcome + public let duration: Duration + + public init(testCase: TestCase, outcome: TestOutcome, duration: Duration) { + self.testCase = testCase + self.outcome = outcome + self.duration = duration + } +} diff --git a/Sources/TestRunner/TestFinder.swift b/Sources/TestRunner/TestFinder.swift new file mode 100644 index 00000000..85d7673c --- /dev/null +++ b/Sources/TestRunner/TestFinder.swift @@ -0,0 +1,72 @@ +import IR + +/// Finds Rego test cases within a compiled IR policy. +/// +/// When OPA compiles a bundle to an IR plan, every reachable rule becomes a +/// `func` even when it is not exposed as a runnable `plan`. Test rules therefore +/// live in `policy.funcs` and can be fished out by inspecting func names. +public enum TestFinder { + /// The `data.` prefix OPA uses for the query name of a rule. + private static let queryPrefix = "data" + + /// Returns the test cases defined by `policy`, in the order the funcs appear. + /// + /// A func is considered a test when: + /// - It is a top-level rule (its path is `["g0", , ]`). + /// - Args are only the implicit `input` and `data` parameters. + /// - Its rule name begins with `test_` (runnable) or `todo_test_` (skipped). + public static func findTests(in policy: IR.Policy) -> [TestCase] { + guard let funcs = policy.funcs?.funcs else { + return [] + } + + let files = policy.staticData?.files ?? [] + + var tests: [TestCase] = [] + for f in funcs { + // Test rules only use the implicit (input, data) params. + guard f.params == [0, 1] else { + continue + } + // Path looks like ["g0", "", ""]. We need at least the + // group tag plus a rule name. + guard f.path.count >= 2, let rule = f.path.last else { + continue + } + + let skipped: Bool + if rule.hasPrefix("todo_test_") { + skipped = true + } else if rule.hasPrefix("test_") { + skipped = false + } else { + continue // Not a test rule? Skip to next func. + } + + // Drop the leading group tag (e.g. "g0"). + // The rest is the package path + rule name. + let pathComponents = Array(f.path.dropFirst()) + let planName = pathComponents.joined(separator: "/") + let name = "\(queryPrefix).\(pathComponents.joined(separator: "."))" + + let location = f.blocks.first?.statements.first?.location + let file = location.flatMap { loc -> String? in + guard loc.file >= 0, loc.file < files.count else { return nil } + return files[loc.file].value + } + let row = location.flatMap { $0.row > 0 ? $0.row : nil } + + tests.append( + TestCase( + name: name, + planName: planName, + funcName: f.name, + skipped: skipped, + file: file, + row: row + ) + ) + } + return tests + } +} diff --git a/Sources/TestRunner/TestPlanGenerator.swift b/Sources/TestRunner/TestPlanGenerator.swift new file mode 100644 index 00000000..bd84cf89 --- /dev/null +++ b/Sources/TestRunner/TestPlanGenerator.swift @@ -0,0 +1,61 @@ +import IR + +/// Generates ad-hoc IR plans that invoke a test func and surface its result. +/// +/// swift-opa resolves called funcs *per policy*, so a wrapper plan must live in +/// the same ``IR/Policy`` as the func it calls. ``TestRunner`` uses these helpers +/// to integrate wrapper plans into a copy of the policy that already holds the +/// test funcs. +public enum TestPlanGenerator { + /// The object key in the result set that stores the test's return value. + /// Mirrors OPA's own plan wrapper, of the form: `{"result": }` + public static let resultKey = "result" + + // Reserved implicit locals present in every plan/func frame. + private static let inputLocal: Local = 0 + private static let dataLocal: Local = 1 + // Locals introduced by the wrapper itself. + private static let callResultLocal: Local = 2 + private static let objectLocal: Local = 3 + + /// Builds the wrapper plan for a runnable test. + /// + /// The plan calls `funcName` with the implicit `input` and `data` arguments, + /// wraps its return value in `{"result": }`, and adds that object to + /// the result set. When the test func is undefined (such as when the test failed), + /// the call result local stays undefined and the `ObjectInsert`/`ResultSetAdd` + /// statements are skipped, leaving an empty result set. + /// + /// - Parameters: + /// - planName: The IR plan name, e.g. `authz_test/test_post_allowed`. + /// - funcName: The IR func name to invoke, e.g. `g0.data.authz_test.test_post_allowed`. + /// - resultStringIndex: Index of the `"result"` string in the policy's static string table. + public static func makeWrapperPlan( + planName: String, + funcName: String, + resultStringIndex: Int + ) -> IR.Plan { + let statements: [IR.Statement] = [ + .callStmt( + IR.CallStatement( + callFunc: funcName, + args: [ + IR.Operand(type: .local, value: .localIndex(Int(inputLocal))), + IR.Operand(type: .local, value: .localIndex(Int(dataLocal))), + ], + result: callResultLocal + ) + ), + .makeObjectStmt(IR.MakeObjectStatement(target: objectLocal)), + .objectInsertStmt( + IR.ObjectInsertStatement( + key: IR.Operand(type: .stringIndex, value: .stringIndex(resultStringIndex)), + value: IR.Operand(type: .local, value: .localIndex(Int(callResultLocal))), + object: objectLocal + ) + ), + .resultSetAddStmt(IR.ResultSetAddStatement(value: objectLocal)), + ] + return IR.Plan(name: planName, blocks: [IR.Block(statements: statements)]) + } +} diff --git a/Sources/TestRunner/TestRunner.swift b/Sources/TestRunner/TestRunner.swift new file mode 100644 index 00000000..cacc2393 --- /dev/null +++ b/Sources/TestRunner/TestRunner.swift @@ -0,0 +1,181 @@ +import AST +import Foundation +import IR +import Rego + +/// Runs Rego `test_*` rules found in compiled IR plan bundles. +/// +/// ``TestFinder`` finds test funcs, then ``integrate(_:)`` replaces a policy's +/// plans with one ad-hoc wrapper plan per runnable test, and +/// ``run(bundles:runFilter:count:)`` evaluates each plan wrapper. +public enum TestRunner { + /// Replaces `policy`'s plans with one test plan per test func. + /// + /// We drop the original plans because the test runner only needs the + /// test funcs and the data, never the original entrypoints. The test + /// plans generated here are thin wrapper around each test func. + /// + /// The returned policy retains the original funcs and static data (with a + /// `"result"` string added, if needed). Skipped tests are reported in the + /// returned list but get no plan. A policy with no runnable tests is + /// returned unchanged. + public static func integrate(_ policy: IR.Policy) -> (policy: IR.Policy, tests: [TestCase]) { + let tests = TestFinder.findTests(in: policy) + let runnable = tests.filter { !$0.skipped } + guard !runnable.isEmpty else { + return (policy, tests) + } + + var newPolicy = policy + + // Ensure the "result" key exists in the static string table and + // record its index for use in the plan generator. + var staticData = newPolicy.staticData ?? IR.Static() + var strings = staticData.strings ?? [] + let resultIndex: Int + if let idx = strings.firstIndex(where: { $0.value == TestPlanGenerator.resultKey }) { + resultIndex = idx + } else { + resultIndex = strings.count + strings.append(IR.ConstString(value: TestPlanGenerator.resultKey)) + } + staticData.strings = strings + newPolicy.staticData = staticData + + // Replace the original plans with our "wrapper" plans. + // Names keep the test's package path (e.g. `authz/rbac_test/test_x`), + // so they should be unique and stay under the bundle's roots. + let wrapperPlans = runnable.map { test in + TestPlanGenerator.makeWrapperPlan( + planName: test.planName, + funcName: test.funcName, + resultStringIndex: resultIndex + ) + } + newPolicy.plans = IR.Plans(plans: wrapperPlans) + + return (newPolicy, tests) + } + + /// Finds and runs every test found in the bundles at `paths`. + /// + /// Each path is loaded with ``BundleLoader``. Bundles' names are + /// the filesystem paths they were loaded from. + public static func run( + paths: [URL], + runFilter: String? = nil, + count: Int = 1 + ) async throws -> [TestResult] { + var bundles: [String: OPA.Bundle] = [:] + for path in paths { + bundles[path.path] = try BundleLoader.load(fromFile: path) + } + return try await run(bundles: bundles, runFilter: runFilter, count: count) + } + + /// Finds and runs every test found in `bundles`. + /// + /// Each bundle's plan files are integrated (see ``integrate(_:)``) and + /// re-encoded in place, then all of those bundles are loaded onto a single + /// ``OPA/Engine``. This means that any conflicts between bundles will + /// surface exactly the same as loading and running those bundles normally. + /// + /// - Parameters: + /// - bundles: Loaded plan bundles keyed by name. + /// - runFilter: Optional regular expression. Only tests with matching names are run. + /// - count: Number of times to repeat each test (default: 1). + /// - Returns: One ``TestResult`` per (test, repetition), in the order tests are found. + public static func run( + bundles: [String: OPA.Bundle], + runFilter: String? = nil, + count: Int = 1 + ) async throws -> [TestResult] { + var integratedBundles: [String: OPA.Bundle] = [:] + var tests: [TestCase] = [] + + // Sort by name for deterministic test ordering across bundles. + for (name, bundle) in bundles.sorted(by: { $0.key < $1.key }) { + var newPlanFiles: [BundleFile] = [] + for planFile in bundle.planFiles { + let policy = try IR.Policy(jsonData: planFile.data) + let (integrated, found) = integrate(policy) + tests.append(contentsOf: found) + let encoded = try JSONEncoder().encode(integrated) + newPlanFiles.append(BundleFile(url: planFile.url, data: encoded)) + } + // Preserve the manifest, rego files, and data. Only the plan + // files change. The engine validates roots/overlap when preparing. + integratedBundles[name] = try OPA.Bundle( + manifest: bundle.manifest, + planFiles: newPlanFiles, + regoFiles: bundle.regoFiles, + data: bundle.data + ) + } + + var engine = OPA.Engine(bundles: integratedBundles) + return try await execute(engine: &engine, tests: tests, runFilter: runFilter, count: count) + } + + /// Evaluates each test against `engine`. + /// + /// Skipped tests are reported without evaluation. Any error thrown while + /// preparing/evaluating a test are thrown upward. + private static func execute( + engine: inout OPA.Engine, + tests: [TestCase], + runFilter: String?, + count: Int + ) async throws -> [TestResult] { + let filtered = try filter(tests, runFilter: runFilter) + + let clock = ContinuousClock() + var results: [TestResult] = [] + + for _ in 0.. Bool { + for value in resultSet { + if case .object(let object) = value, + object[.string(TestPlanGenerator.resultKey)] == .boolean(true) + { + return true + } + } + return false + } + + /// Applies the `--run` regular-expression filter to `tests`, matching against + /// each test's fully-qualified name. + static func filter(_ tests: [TestCase], runFilter: String?) throws -> [TestCase] { + guard let runFilter, !runFilter.isEmpty else { + return tests + } + let regex = try Regex(runFilter) + return try tests.filter { try regex.firstMatch(in: $0.name) != nil } + } +} diff --git a/Tests/TestRunnerTests/Fixtures/README.md b/Tests/TestRunnerTests/Fixtures/README.md new file mode 100644 index 00000000..776ddf1b --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/README.md @@ -0,0 +1,23 @@ +# TestRunner test fixtures + +Each subdirectory is a compiled Rego **plan bundle** (`opa build -t plan` output), +committed with its `.rego` sources so the fixtures are easy to inspect and regenerate. +The `.rego` files are not needed at runtime (swift-opa executes `plan.json`). They are +kept for debugging and to document what each plan was compiled from. + +`test_*` rules only appear in `plan.json` as funcs when they are reachable from the +build's entrypoints. The commands below choose entrypoints accordingly. + +## `example-bundle/` + +```sh +cd example-bundle +opa build -b . -t plan -e example_test -o ../b.tar.gz && tar -xzf ../b.tar.gz +``` + +## `nested-bundle/` + +```sh +cd nested-bundle +opa build -b . -t plan -e authz/allow -e authz/rbac/allow -o ../b.tar.gz && tar -xzf ../b.tar.gz +``` diff --git a/Tests/TestRunnerTests/Fixtures/example-bundle/.manifest b/Tests/TestRunnerTests/Fixtures/example-bundle/.manifest new file mode 100644 index 00000000..602dc133 --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/example-bundle/.manifest @@ -0,0 +1 @@ +{"revision":"","roots":[""],"rego_version":1} diff --git a/Tests/TestRunnerTests/Fixtures/example-bundle/data.json b/Tests/TestRunnerTests/Fixtures/example-bundle/data.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/example-bundle/data.json @@ -0,0 +1 @@ +{} diff --git a/Tests/TestRunnerTests/Fixtures/example-bundle/example.rego b/Tests/TestRunnerTests/Fixtures/example-bundle/example.rego new file mode 100644 index 00000000..3427f401 --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/example-bundle/example.rego @@ -0,0 +1,7 @@ +package example + +default allow := false + +allow if { + input.role == "admin" +} diff --git a/Tests/TestRunnerTests/Fixtures/example-bundle/example_test.rego b/Tests/TestRunnerTests/Fixtures/example-bundle/example_test.rego new file mode 100644 index 00000000..0ae60975 --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/example-bundle/example_test.rego @@ -0,0 +1,19 @@ +package example_test + +import data.example + +test_pass if { + true +} + +test_allow_admin if { + example.allow with input as {"role": "admin"} +} + +test_fail if { + false +} + +todo_test_skip if { + true +} diff --git a/Tests/TestRunnerTests/Fixtures/example-bundle/plan.json b/Tests/TestRunnerTests/Fixtures/example-bundle/plan.json new file mode 100644 index 00000000..83b60ce1 --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/example-bundle/plan.json @@ -0,0 +1 @@ +{"static":{"strings":[{"value":"result"},{"value":"test_allow_admin"},{"value":"role"},{"value":"admin"},{"value":"test_fail"},{"value":"test_pass"},{"value":"todo_test_skip"},{"value":"example_test"}],"files":[{"value":"example_test.rego"},{"value":"example.rego"}]},"plans":{"plans":[{"name":"example_test","blocks":[{"stmts":[{"type":"MakeObjectStmt","stmt":{"target":2,"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.example_test.test_allow_admin","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":3,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":1},"value":{"type":"local","value":3},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.example_test.test_fail","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":4},"value":{"type":"local","value":4},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.example_test.test_pass","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":5,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":5},"value":{"type":"local","value":5},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.example_test.todo_test_skip","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":6},"value":{"type":"local","value":6},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"DotStmt","stmt":{"source":{"type":"local","value":1},"key":{"type":"string_index","value":7},"target":8,"file":0,"col":0,"row":0}},{"type":"ObjectMergeStmt","stmt":{"a":8,"b":2,"target":7,"file":0,"col":0,"row":0}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":7,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":7},"target":9,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":10,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":9},"object":10,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":10,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.example.allow","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":1,"col":1,"row":5}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":2},"target":4,"file":1,"col":2,"row":6}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":4},"b":{"type":"string_index","value":3},"file":1,"col":2,"row":6}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":1,"col":1,"row":5}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":1,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":1,"col":1,"row":5}}]},{"stmts":[{"type":"IsUndefinedStmt","stmt":{"source":2,"file":1,"col":9,"row":3}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":false},"target":2,"file":1,"col":9,"row":3}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":1,"col":9,"row":3}}]}],"path":["g0","example","allow"]},{"name":"g0.data.example_test.test_allow_admin","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":9}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":2,"row":10}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":2},"value":{"type":"string_index","value":3},"object":4,"file":0,"col":2,"row":10}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":0},"target":5,"file":0,"col":2,"row":10}},{"type":"WithStmt","stmt":{"local":0,"path":[],"value":{"type":"local","value":4},"block":{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.example.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":0,"col":2,"row":10}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":6},"b":{"type":"bool","value":false},"file":0,"col":2,"row":10}},{"type":"WithStmt","stmt":{"local":0,"path":null,"value":{"type":"local","value":5},"block":{"stmts":[{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":9}}]},"file":0,"col":2,"row":10}}]},"file":0,"col":2,"row":10}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":9}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":9}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":9}}]}],"path":["g0","example_test","test_allow_admin"]},{"name":"g0.data.example_test.test_fail","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":13}},{"type":"BreakStmt","stmt":{"index":0,"file":0,"col":2,"row":14}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":13}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":13}}]}],"path":["g0","example_test","test_fail"]},{"name":"g0.data.example_test.test_pass","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":5}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":5}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":5}}]}],"path":["g0","example_test","test_pass"]},{"name":"g0.data.example_test.todo_test_skip","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":17}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":17}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":17}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":17}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":17}}]}],"path":["g0","example_test","todo_test_skip"]}]}} \ No newline at end of file diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/.manifest b/Tests/TestRunnerTests/Fixtures/nested-bundle/.manifest new file mode 100644 index 00000000..602dc133 --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/.manifest @@ -0,0 +1 @@ +{"revision":"","roots":[""],"rego_version":1} diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/authz.rego b/Tests/TestRunnerTests/Fixtures/nested-bundle/authz.rego new file mode 100644 index 00000000..c0400bb0 --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/authz.rego @@ -0,0 +1,4 @@ +package authz + +default allow := false +allow if { input.role == "admin" } diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/authz_test.rego b/Tests/TestRunnerTests/Fixtures/nested-bundle/authz_test.rego new file mode 100644 index 00000000..f55a3961 --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/authz_test.rego @@ -0,0 +1,6 @@ +package authz_test + +import data.authz + +test_admin_allowed if { authz.allow with input as {"role": "admin"} } +test_anon_denied if { not authz.allow with input as {"role": "guest"} } diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/data.json b/Tests/TestRunnerTests/Fixtures/nested-bundle/data.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/data.json @@ -0,0 +1 @@ +{} diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/plan.json b/Tests/TestRunnerTests/Fixtures/nested-bundle/plan.json new file mode 100644 index 00000000..e20f14cf --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/plan.json @@ -0,0 +1 @@ +{"static":{"strings":[{"value":"result"},{"value":"role"},{"value":"admin"},{"value":"perm"},{"value":"read"},{"value":"write"},{"value":"guest"}],"files":[{"value":"authz.rego"},{"value":"rbac.rego"},{"value":"rbac_test.rego"},{"value":"authz_test.rego"}]},"plans":{"plans":[{"name":"authz/allow","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":2,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":3,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":3},"object":4,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":4,"file":0,"col":0,"row":0}}]}]},{"name":"authz/rbac/allow","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.rbac.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":5,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":5},"target":6,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":7,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":6},"object":7,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":7,"file":0,"col":0,"row":0}}]}]},{"name":"authz/rbac_test/test_read_allowed","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.rbac_test.test_read_allowed","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":8,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":8},"target":9,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":10,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":9},"object":10,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":10,"file":0,"col":0,"row":0}}]}]},{"name":"authz/rbac_test/test_write_denied","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.rbac_test.test_write_denied","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":11,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":11},"target":12,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":13,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":12},"object":13,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":13,"file":0,"col":0,"row":0}}]}]},{"name":"authz_test/test_admin_allowed","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz_test.test_admin_allowed","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":14,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":14},"target":15,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":16,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":15},"object":16,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":16,"file":0,"col":0,"row":0}}]}]},{"name":"authz_test/test_anon_denied","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz_test.test_anon_denied","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":17,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":17},"target":18,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":19,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":18},"object":19,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":19,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.authz.allow","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":4}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":12,"row":4}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":4},"b":{"type":"string_index","value":2},"file":0,"col":12,"row":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":4}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":4}}]},{"stmts":[{"type":"IsUndefinedStmt","stmt":{"source":2,"file":0,"col":9,"row":3}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":false},"target":2,"file":0,"col":9,"row":3}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":9,"row":3}}]}],"path":["g0","authz","allow"]},{"name":"g0.data.authz.rbac.allow","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":1,"col":1,"row":4}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":3},"target":4,"file":1,"col":12,"row":4}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":4},"b":{"type":"string_index","value":4},"file":1,"col":12,"row":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":1,"col":1,"row":4}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":1,"col":1,"row":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":1,"col":1,"row":4}}]},{"stmts":[{"type":"IsUndefinedStmt","stmt":{"source":2,"file":1,"col":9,"row":3}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":false},"target":2,"file":1,"col":9,"row":3}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":1,"col":9,"row":3}}]}],"path":["g0","authz","rbac","allow"]},{"name":"g0.data.authz.rbac_test.test_read_allowed","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":2,"col":1,"row":5}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":2,"col":24,"row":5}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":3},"value":{"type":"string_index","value":4},"object":4,"file":2,"col":24,"row":5}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":0},"target":5,"file":2,"col":24,"row":5}},{"type":"WithStmt","stmt":{"local":0,"path":[],"value":{"type":"local","value":4},"block":{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.rbac.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":2,"col":24,"row":5}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":6},"b":{"type":"bool","value":false},"file":2,"col":24,"row":5}},{"type":"WithStmt","stmt":{"local":0,"path":null,"value":{"type":"local","value":5},"block":{"stmts":[{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":2,"col":1,"row":5}}]},"file":2,"col":24,"row":5}}]},"file":2,"col":24,"row":5}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":2,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":2,"col":1,"row":5}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":2,"col":1,"row":5}}]}],"path":["g0","authz","rbac_test","test_read_allowed"]},{"name":"g0.data.authz.rbac_test.test_write_denied","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":2,"col":1,"row":6}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":2,"col":24,"row":6}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":3},"value":{"type":"string_index","value":5},"object":4,"file":2,"col":24,"row":6}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":0},"target":5,"file":2,"col":24,"row":6}},{"type":"WithStmt","stmt":{"local":0,"path":[],"value":{"type":"local","value":4},"block":{"stmts":[{"type":"NotStmt","stmt":{"block":{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.rbac.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":2,"col":24,"row":6}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":6},"b":{"type":"bool","value":false},"file":2,"col":24,"row":6}}]},"file":2,"col":24,"row":6}},{"type":"WithStmt","stmt":{"local":0,"path":null,"value":{"type":"local","value":5},"block":{"stmts":[{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":2,"col":1,"row":6}}]},"file":2,"col":24,"row":6}}]},"file":2,"col":24,"row":6}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":2,"col":1,"row":6}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":2,"col":1,"row":6}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":2,"col":1,"row":6}}]}],"path":["g0","authz","rbac_test","test_write_denied"]},{"name":"g0.data.authz_test.test_admin_allowed","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":3,"col":1,"row":5}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":3,"col":25,"row":5}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":1},"value":{"type":"string_index","value":2},"object":4,"file":3,"col":25,"row":5}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":0},"target":5,"file":3,"col":25,"row":5}},{"type":"WithStmt","stmt":{"local":0,"path":[],"value":{"type":"local","value":4},"block":{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":3,"col":25,"row":5}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":6},"b":{"type":"bool","value":false},"file":3,"col":25,"row":5}},{"type":"WithStmt","stmt":{"local":0,"path":null,"value":{"type":"local","value":5},"block":{"stmts":[{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":3,"col":1,"row":5}}]},"file":3,"col":25,"row":5}}]},"file":3,"col":25,"row":5}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":3,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":3,"col":1,"row":5}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":3,"col":1,"row":5}}]}],"path":["g0","authz_test","test_admin_allowed"]},{"name":"g0.data.authz_test.test_anon_denied","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":3,"col":1,"row":6}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":3,"col":23,"row":6}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":1},"value":{"type":"string_index","value":6},"object":4,"file":3,"col":23,"row":6}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":0},"target":5,"file":3,"col":23,"row":6}},{"type":"WithStmt","stmt":{"local":0,"path":[],"value":{"type":"local","value":4},"block":{"stmts":[{"type":"NotStmt","stmt":{"block":{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":3,"col":23,"row":6}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":6},"b":{"type":"bool","value":false},"file":3,"col":23,"row":6}}]},"file":3,"col":23,"row":6}},{"type":"WithStmt","stmt":{"local":0,"path":null,"value":{"type":"local","value":5},"block":{"stmts":[{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":3,"col":1,"row":6}}]},"file":3,"col":23,"row":6}}]},"file":3,"col":23,"row":6}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":3,"col":1,"row":6}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":3,"col":1,"row":6}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":3,"col":1,"row":6}}]}],"path":["g0","authz_test","test_anon_denied"]}]}} \ No newline at end of file diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac.rego b/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac.rego new file mode 100644 index 00000000..35a6df7a --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac.rego @@ -0,0 +1,4 @@ +package authz.rbac + +default allow := false +allow if { input.perm == "read" } diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac_test.rego b/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac_test.rego new file mode 100644 index 00000000..faa1ffa0 --- /dev/null +++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac_test.rego @@ -0,0 +1,6 @@ +package authz.rbac_test + +import data.authz.rbac + +test_read_allowed if { rbac.allow with input as {"perm": "read"} } +test_write_denied if { not rbac.allow with input as {"perm": "write"} } diff --git a/Tests/TestRunnerTests/TestRunnerTests.swift b/Tests/TestRunnerTests/TestRunnerTests.swift new file mode 100644 index 00000000..336a96f7 --- /dev/null +++ b/Tests/TestRunnerTests/TestRunnerTests.swift @@ -0,0 +1,166 @@ +import AST +import Foundation +import IR +import Rego +import Testing + +@testable import TestRunner + +@Suite("TestRunner") +struct TestRunnerTests { + /// URL of the extracted plan bundle fixture (built via + /// `opa build -b . -t plan -e example_test`, so all test rules appear as funcs). + static var bundleURL: URL { + Bundle.module.resourceURL!.appending(path: "Fixtures/example-bundle") + } + + /// Decodes the fixture bundle's single plan file into an IR policy. + static func fixturePolicy() throws -> IR.Policy { + let bundle = try BundleLoader.load(fromFile: bundleURL) + let planFile = try #require(bundle.planFiles.first) + return try IR.Policy(jsonData: planFile.data) + } + + // MARK: - Finding tests + + @Test("finds test funcs, classifying todo_ as skipped") + func findTests() throws { + let policy = try Self.fixturePolicy() + let tests = TestFinder.findTests(in: policy) + + let byName = Dictionary(uniqueKeysWithValues: tests.map { ($0.name, $0) }) + #expect( + Set(byName.keys) == [ + "data.example_test.test_pass", + "data.example_test.test_fail", + "data.example_test.test_allow_admin", + "data.example_test.todo_test_skip", + ]) + + let pass = try #require(byName["data.example_test.test_pass"]) + #expect(pass.planName == "example_test/test_pass") + #expect(pass.funcName == "g0.data.example_test.test_pass") + #expect(pass.skipped == false) + #expect(pass.file == "example_test.rego") + #expect(pass.row != nil) + + let skip = try #require(byName["data.example_test.todo_test_skip"]) + #expect(skip.skipped == true) + } + + // MARK: - Integration (plan generation) + + @Test("integrate replaces plans with exactly one wrapper per runnable test") + func integrate() throws { + let policy = try Self.fixturePolicy() + let (integrated, tests) = TestRunner.integrate(policy) + + let runnable = tests.filter { !$0.skipped } + #expect(runnable.count == 3) + + // The plan list is exactly the runnable-test wrappers. The fixture's + // original package-level plan ("example_test") is dropped, and the + // skipped todo_ test gets no plan. + let planNames = Set((integrated.plans?.plans ?? []).map(\.name)) + #expect( + planNames == [ + "example_test/test_pass", + "example_test/test_fail", + "example_test/test_allow_admin", + ]) + + // The "result" key must be present in the static string table. + let strings = integrated.staticData?.strings?.map(\.value) ?? [] + #expect(strings.contains(TestPlanGenerator.resultKey)) + + // Funcs are preserved. + #expect(integrated.funcs?.funcs?.count == policy.funcs?.funcs?.count) + } + + // MARK: - Strict result-set interpretation + + @Test("passed() requires a result object whose `result` field is true") + func strictResultSemantics() { + let key = AST.RegoValue.string(TestPlanGenerator.resultKey) + + // Bare true value under `result` -> pass. + #expect(TestRunner.passed([.object([key: .boolean(true)])])) + // Extra annotation keys are tolerated as long as `result` is true. + #expect(TestRunner.passed([.object([key: .boolean(true), .string("trace"): .array([])])])) + // `result` is false -> not a pass. + #expect(!TestRunner.passed([.object([key: .boolean(false)])])) + // Object without a `result` key -> not a pass. + #expect(!TestRunner.passed([.object([.string("other"): .boolean(true)])])) + // A bare true (not wrapped in an object) -> not a pass. + #expect(!TestRunner.passed([.boolean(true)])) + // Empty result set (undefined test) -> not a pass. + #expect(!TestRunner.passed([])) + } + + // MARK: - End-to-end run + + @Test("run classifies pass/fail/skip against the fixture bundle") + func endToEnd() async throws { + let results = try await TestRunner.run(paths: [Self.bundleURL]) + let outcomes = Dictionary( + uniqueKeysWithValues: results.map { ($0.testCase.name, $0.outcome) }) + + #expect(outcomes["data.example_test.test_pass"] == .passed) + #expect(outcomes["data.example_test.test_allow_admin"] == .passed) + #expect(outcomes["data.example_test.test_fail"] == .failed) + #expect(outcomes["data.example_test.todo_test_skip"] == .skipped) + } + + /// URL of a bundle with nested packages and two separate test packages, + /// built with production entrypoints only (`-e authz/allow -e authz/rbac/allow`) + /// so tests are pulled in via reachability and appear as funcs. + static var nestedBundleURL: URL { + Bundle.module.resourceURL!.appending(path: "Fixtures/nested-bundle") + } + + @Test("nested packages yield one unique plan/result per test") + func nestedPackages() async throws { + // Discovery derives a distinct per-test plan name for each test, including + // the nested `authz.rbac_test` package — no package-level plan is shared. + let bundle = try BundleLoader.load(fromFile: Self.nestedBundleURL) + let policy = try IR.Policy(jsonData: #require(bundle.planFiles.first).data) + let (integrated, tests) = TestRunner.integrate(policy) + + #expect(tests.count == 4) + #expect( + Set((integrated.plans?.plans ?? []).map(\.name)) == [ + "authz_test/test_admin_allowed", + "authz_test/test_anon_denied", + "authz/rbac_test/test_read_allowed", + "authz/rbac_test/test_write_denied", + ]) + + // End-to-end: every test runs and passes, one result each. + let results = try await TestRunner.run(paths: [Self.nestedBundleURL]) + #expect(results.count == 4) + #expect(results.allSatisfy { $0.outcome == .passed }) + #expect( + Set(results.map(\.testCase.name)) == [ + "data.authz_test.test_admin_allowed", + "data.authz_test.test_anon_denied", + "data.authz.rbac_test.test_read_allowed", + "data.authz.rbac_test.test_write_denied", + ]) + } + + @Test("--run filter restricts which tests execute") + func runFilter() async throws { + let results = try await TestRunner.run(paths: [Self.bundleURL], runFilter: "test_pass") + #expect(results.count == 1) + #expect(results.first?.testCase.name == "data.example_test.test_pass") + #expect(results.first?.outcome == .passed) + } + + @Test("--count repeats each test") + func count() async throws { + let single = try await TestRunner.run(paths: [Self.bundleURL], runFilter: "test_pass") + let doubled = try await TestRunner.run( + paths: [Self.bundleURL], runFilter: "test_pass", count: 2) + #expect(doubled.count == single.count * 2) + } +}