Skip to content
Merged
7 changes: 5 additions & 2 deletions demo-application/tests/accessControlTesting.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// todo: replace this with from 'access-control-testing' later
import { Act, Resource, User } from '../../src/api/index.ts'
import { NodeTestRunner } from '../../src/core/tests/runner/node-test-runner.ts'

const test = async () => {
// todo: seed the database with testing data
Expand All @@ -24,7 +24,10 @@ const test = async () => {
users,
resources,
})
await act.scan()

const testCases = await act.generateTestCases()
const testRunner = new NodeTestRunner()
await testRunner.run(testCases)
}

test()
29 changes: 10 additions & 19 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,29 @@
import { OpenAPIParser } from "../core/parsers/openapi-parser.ts";
import { Resource } from "../core/policy/entities/resource.ts";
import { User } from "../core/policy/entities/user.ts";
import {
TestRunnerFactory,
type TestRunnerIdentifier,
} from "../core/tests/runner/test-runner.ts";
import { TestExecutor } from "../core/tests/test-executor.ts";
import { TestcaseGenerator } from "../core/tests/testcase-generator.ts";

type ActOptions = {
apiBaseUrl: string;
openApiUrl: string;
users: Array<User>;
resources: Array<Resource>;
testRunner?: TestRunnerIdentifier;
};

class Act {
constructor(private readonly options: ActOptions) {
// todo: check if both are valid urls
}

async scan() {
console.log("Scanning...");
async generateTestCases() {
console.log("Generating testcases...");
const { openApiUrl, apiBaseUrl, users, resources } = this.options;

const testRunner = this.options.testRunner
? TestRunnerFactory.createTestRunner(this.options.testRunner)
: TestRunnerFactory.createTestRunner();
const openAPIParser = await OpenAPIParser.create(openApiUrl, apiBaseUrl);
openAPIParser.validateCustomFields(resources);

const testExecutor = new TestExecutor();
await testExecutor.runTests(
testRunner,
this.options.openApiUrl,
this.options.apiBaseUrl,
this.options.users,
this.options.resources,
);
const testCaseGenerator = new TestcaseGenerator(openAPIParser, users);
return testCaseGenerator.generateTestCases();
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/core/policy/policy-decision-point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { type Action, type ResourceIdentifier } from "./types.ts";
export const PolicyDecisionPoint = {
// todo: some resources could be public for all (-> make that configurable)
/**
* Decides if a user is allowed to perform an action on a resource based on
* Decides if a user is permitted to perform an action on a resource based on
* the user's privileges that are derived from the user's relationships to
* resources.
*
Expand All @@ -16,8 +16,8 @@ export const PolicyDecisionPoint = {
* to perform the action on
* @param resourceIdentifier The identifier of the specific resource the user
* wants to perform the action on
* @returns True if the user is allowed to perform the action on the resource,
* false otherwise
* @returns True if the user is permitted to perform the action on the
* resource, false otherwise
*/
isAllowed(
user: User,
Expand Down
42 changes: 23 additions & 19 deletions src/core/tests/runner/node-test-runner.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Expectation, TestRunner } from "./test-runner.ts";
import { test } from "node:test";
import { TestRunner, type TestCase } from "./test-runner.ts";

export class NodeTestRunner implements TestRunner {
group(name: string, callback: () => void) {
void describe(name, callback);
}
const expect = (actual: unknown) => ({
toBe: (expected: unknown) => assert.strictEqual(actual, expected),
notToBe: (expected: unknown) => assert.notStrictEqual(actual, expected),
});

test(
name: string,
callback: (t: { skip: (reason?: string) => void }) => Promise<void> | void,
) {
void it(name, async (t) =>
callback({ skip: (reason?: string) => t.skip(reason) }),
);
}
export class NodeTestRunner extends TestRunner {
override async run(testCases: Array<TestCase>) {
await Promise.all(
testCases.map((testCase) =>
test(testCase.name, async () => {
const testResult = await testCase.test({
expect,
skip: (reason) => {
void test.skip(reason);
},
});

expect(actual: any): Expectation {
return {
toBe: (expected) => assert.strictEqual(actual, expected),
notToBe: (expected) => assert.notStrictEqual(actual, expected),
};
if (testResult) {
this.testResults.push(testResult);
}
}),
),
);
}
}
85 changes: 51 additions & 34 deletions src/core/tests/runner/test-runner.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,59 @@
import { NodeTestRunner } from "./node-test-runner.ts";
import { User } from "../../policy/entities/user";
import { Route } from "../test-utils";

export type Expectation = {
toBe: (expected: any) => void;
notToBe: (expected: any) => void;
export type AccessControlResult = "permitted" | "denied";

export type TestResult = {
user: User | null;
route: Route;
expected: AccessControlResult;
actual?: AccessControlResult;
testResult?: "✅" | "❌" | "⏭️";
};

export type TestContext = {
skip: (reason?: string) => void;
export type Expectation = (actual: unknown) => {
toBe: (expected: unknown) => void;
notToBe: (expected: unknown) => void;
};

// todo: expose TestRunner in api so a custom test-runner can be used when implementing this interface
export type TestRunner = {
group: (name: string, callback: () => void) => void;
test: (
name: string,
callback: (t: TestContext) => Promise<void> | void,
) => void;
expect: (actual: any) => Expectation;
export type TestCaseFunction = (
testContext: TestContext,
) => Promise<TestResult | undefined>;

export type TestCase = {
name: string;
test: TestCaseFunction;
};

export type TestRunnerIdentifier = "node" | "jest";

const DEFAULT_TEST_RUNNER = "node";
export const TestRunnerFactory = {
createTestRunner(
runner: TestRunnerIdentifier = DEFAULT_TEST_RUNNER,
): TestRunner {
switch (runner) {
case "node": {
return new NodeTestRunner();
}
/*case "jest":
return new JestTestRunner();
case "japa":
return new JapaTestRunner();*/
default: {
throw new Error(`Unsupported test runner: ${runner}`);
}
}
},
export type TestContext = {
expect: Expectation;
/**
* Skip the current test case. For test runners that do not support skipping
* an already running test case, an appropriate warning should be displayed.
*/
skip: (reason?: string) => void;
};

export abstract class TestRunner {
protected testResults: Array<TestResult> = [];

constructor() {
process.on("beforeExit", () => this.printReport());
}

abstract run(testCases: Array<TestCase>): Promise<void>;

protected printReport(): void {
console.log("\n=== Test Report ===");

const transformedResults = this.testResults.map((result) => ({
...result,
user: result.user?.toString() ?? "anonymous",
route: result.route.toString(),
}));

console.table(transformedResults);

// todo: enhance this with a detailed report containing all the routes that failed
}
}
Loading
Loading