Testing Framework
Comprehensive testing for PolicyFlow policies
Authorization logic is critical infrastructure; therefore, testing is a first-class citizen in PolicyFlow. The testing framework is designed to be expressive, enabling you to validate every aspect of your policies with confidence.
Test Suite Structure
A test suite is defined in a .pftest
file and is bound to a specific policy:
// Single policy file - name can be omitted
import * as DocPolicy from "@/policies/documents"
test DocumentAccessTests for DocPolicy {
// Optional setup block
setup {
// Define reusable test data
}
// Test cases
case "Test case description" {
// Test implementation
}
// Property-based tests
property "Property description" {
// Property implementation
}
}
Example-Based Testing (Case)
The case
block is for writing specific test scenarios with concrete inputs and expected outputs.
Basic Test Case
test SimpleTests for MyPolicy {
case "Owners can access their resources" {
given {
user: {
id: "user-123",
name: "Alice",
roles: ["user"]
}
resource: {
id: "doc-456",
ownerId: "user-123",
title: "My Document"
}
action: "read"
}
expect: ALLOW
reason: "Resource owner" // Optional: verify the reason
}
}
Testing Different Scenarios
test ComprehensiveTests for DocumentPolicy {
case "Admin can delete any document" {
given {
user: {
id: "admin-1",
roles: ["admin"],
isActive: true
}
resource: {
id: "doc-789",
ownerId: "user-456",
classification: "Confidential"
}
action: "delete"
}
expect: ALLOW
}
case "Inactive users are always denied" {
given {
user: {
id: "user-999",
roles: ["admin"], // Even admin
isActive: false
}
resource: {
id: "doc-123",
ownerId: "user-999" // Even owner
}
action: "read"
}
expect: DENY
reason: "User account is inactive"
}
case "Users cannot access archived documents" {
given {
user: {
id: "user-123",
roles: ["user"],
isActive: true
}
resource: {
id: "doc-old",
ownerId: "user-123",
status: "archived"
}
action: "read"
}
expect: DENY
}
}
Setup Block
The setup
block allows you to define reusable test data, particularly relationships between entities.
Defining Relationships
Relationships in tests use strict type matching - the type names must match exactly with types defined in your schemas:
test RelationshipTests for TeamAccessPolicy {
setup {
relationships {
// Simple relationships without attributes
member_of(
Employee{id: "user-1"}, // Must match a User type in schemas
Team{id: "team-engineering"} // Must match a type in schemas
)
owns(
Team{id: "team-engineering"},
Document{id: "eng-docs"}
)
// Free-form attributes for simple cases
collaborates_on(
Employee{id: "user-2"},
Document{id: "shared-doc"}
) {
// Any attributes can be added
permissions: ["read", "comment"]
since: DateTime.Parse("2025-06-01")
customField: "any value"
}
}
}
case "Team members can access team resources" {
given {
user: { id: "user-1" }
resource: { id: "eng-docs" }
action: "read"
}
expect: ALLOW
}
}
Type Resolution Rules
- Unambiguous Types: If a type name is unique across all schemas, use it directly
- Ambiguous Types: If multiple schemas define the same type name, use qualified names
test AmbiguousTypeTest {
setup {
relationships {
// Unambiguous - only one "Document" type exists
owns(
Employee{id: "u1"},
Document{id: "d1"}
)
// Ambiguous - multiple "Employee" types exist
// Use qualified names: SchemaName.TypeName
manages(
Auth.Employee{id: "e1"}, // Employee from AuthSchema
Support.Employee{id: "e2"} // Employee from SupportSchema
)
}
}
}
Compiler Errors
The compiler provides clear errors for ambiguous type references:
Error: Ambiguous type reference 'Employee' in relationship definition.
Multiple types found:
- Auth.Employee (from @/schemas/auth)
- Support.Employee (from @/schemas/support)
Use qualified name: Auth.Employee or Support.Employee
Relationship Attributes
PolicyFlow supports two approaches for relationship attributes:
1. Free-form Attributes (Simple Cases)
For simple relationships, you can add any attributes directly:
setup {
relationships {
likes(
Employee{id: "user-1"},
Post{id: "post-123"}
) {
timestamp: DateTime.Now()
reaction: "heart"
}
member_of(
Employee{id: "user-2"},
Organization{id: "org-1"}
) {
role: "admin"
department: "Engineering"
permissions: ["read", "write", "delete"]
}
}
}
2. Typed Relationships (Complex Cases)
For complex relationships with validation needs, define them as Relationship types:
// In your schema file
schema RelationshipSchema {
Relationship type TeamMembership {
userId: UUID
teamId: UUID
role: String
permissions: String[]
since: DateTime
active: Boolean = true
}
Relationship type ResourceCollaboration {
userId: UUID
resourceId: UUID
level: String
expiresAt: DateTime?
lastAccessed: DateTime?
}
}
// In your test
import * as Rel from "@/schemas/relationships"
test TypedRelationshipTest {
setup {
relationships {
// Create typed relationship instance
Rel.TeamMembership{
userId: "user-1",
teamId: "team-1",
role: "lead",
permissions: ["manage_members", "edit_settings"],
since: DateTime.Parse("2024-01-01")
}
Rel.ResourceCollaboration{
userId: "user-2",
resourceId: "doc-1",
level: "editor",
expiresAt: DateTime.Parse("2025-12-31")
}
}
}
}
Accessing Relationship Attributes in Policies
For Free-form Relationships
Use the Relationships library to access attributes:
rule CheckCollaborationLevel {
when {
// Get relationship with attributes
const rel = Relationships.GetRelationship(user, "collaborates_on", resource)
if (rel == null) return false
// Access attributes with type assertion
const permissions = rel.GetAttribute("permissions");
return permissions?.Contains("write") ?? false
}
then ALLOW
}
For Typed Relationships
Query typed relationships directly:
rule CheckTeamRole {
when {
// Query typed relationship
const membership = Relationships.Get<TeamMembership>(
userId: user.id,
teamId: resource.teamId
)
// Type-safe attribute access
return membership?.role == "admin" && membership.active
}
then ALLOW
}
Property-Based Testing
Property-based testing validates that certain properties hold true for all possible inputs matching specified conditions.
Basic Properties
test SecurityProperties for AccessPolicy {
property "No access without authentication" {
for User where user == null
for Resource
for Action
expect: DENY
}
property "Suspended users are always denied" {
for User where (user?.status ?? "") == "suspended"
for Resource
for Action
expect: DENY
}
property "Deleted resources are inaccessible" {
for User
for Resource where resource?.isDeleted == true
for Action
expect: DENY
}
}
Property Syntax
The for
keyword is used to specify the types and conditions for property testing:
// Basic syntax
for <EntityType> where <condition>
// EntityType must be one of: User, Resource, Context, Action
// The condition can use null-safe navigation (?.) to handle missing properties
Complex Properties
test AdvancedProperties for EnterprisePolicy {
property "MFA required for sensitive operations" {
for User
for Resource where resource?.sensitivity == "high"
for Context where context?.authMethod != "mfa"
for Action where action in ["write", "delete", "share"]
expect: DENY
}
property "Contractors cannot access internal resources outside business hours" {
for User where user?.type == "contractor"
for Resource where resource?.classification == "internal"
for Context where {
const hour = context?.timestamp?.ToUnit(TimeUnit.Hours)
return hour != null AND (hour < 9 OR hour >= 17)
}
for Action
expect: DENY
}
property "Admins can always read, but need approval for destructive actions" {
for User where "admin" in user?.roles
for Resource
for Context
for Action where action == "read"
expect: ALLOW
}
}
Testing Context
The context
object in tests can include any contextual information your policies use:
test ContextTests for LocationBasedPolicy {
case "EU users must access from EU IPs" {
given {
user: {
id: "eu-user",
region: "EU"
}
resource: {
id: "data-1"
}
context: {
ipAddress: "185.10.10.10", // EU IP
requestTime: DateTime.Parse("2025-06-15T14:00:00Z"),
authMethod: "password",
userAgent: "Mozilla/5.0..."
}
action: "read"
}
expect: ALLOW
}
case "EU users blocked from non-EU IPs" {
given {
user: {
id: "eu-user",
region: "EU"
}
resource: {
id: "data-1"
}
context: {
ipAddress: "98.137.149.1", // US IP
requestTime: DateTime.Parse("2025-06-15T14:00:00Z")
}
action: "read"
}
expect: DENY
reason: "EU users must access from EU region"
}
}
Testing Helper Functions
Test policy functions by creating scenarios that exercise them:
// Policy with helper function
policy TimeBasedAccess {
function isBusinessHours(time: DateTime): Boolean {
const hour = time.ToUnit(TimeUnit.Hours);
const day = time.DayOfWeek();
return day >= 1 AND day <= 5 AND hour >= 9 AND hour < 17;
}
rules {
rule BusinessHoursOnly {
when !isBusinessHours(context.requestTime)
then DENY
reason: "Access only allowed during business hours"
}
}
}
// Tests for the policy
test TimeBasedTests for TimeBasedAccess {
case "Allow during business hours" {
given {
user: { id: "user-1" }
resource: { id: "res-1" }
context: {
requestTime: DateTime.Parse("2025-06-11T10:00:00Z") // Wednesday 10 AM
}
action: "read"
}
expect: ALLOW
}
case "Deny on weekend" {
given {
user: { id: "user-1" }
resource: { id: "res-1" }
context: {
requestTime: DateTime.Parse("2025-06-14T10:00:00Z") // Saturday 10 AM
}
action: "read"
}
expect: DENY
reason: "Access only allowed during business hours"
}
}
Test Organization Best Practices
1. Group Related Tests
test DocumentPolicyTests for DocumentPolicy {
// Group: Owner permissions
case "Owner can read" { /* ... */ }
case "Owner can edit" { /* ... */ }
case "Owner can delete" { /* ... */ }
// Group: Sharing permissions
case "Shared users can read" { /* ... */ }
case "Shared users cannot delete" { /* ... */ }
// Group: Admin overrides
case "Admin can read any document" { /* ... */ }
case "Admin can delete with audit" { /* ... */ }
}
2. Test Edge Cases
test EdgeCaseTests for Policy {
case "Empty arrays are handled" {
given {
user: {
id: "user-1",
roles: [] // Empty roles
}
resource: { id: "res-1" }
action: "read"
}
expect: DENY
}
case "Null values are handled" {
given {
user: {
id: "user-1",
manager: null // No manager
}
resource: {
id: "res-1",
metadata: null
}
action: "read"
}
expect: DENY
}
}
3. Use Descriptive Names
// Good: Descriptive test names
case "New users cannot access premium features until email verified" { }
case "Deleted documents return 404 even for owners" { }
// Poor: Vague names
case "Test 1" { }
case "Check access" { }
Testing with Type Defaults
When testing policies that use types with default values:
// Schema with defaults
schema TestSchema {
User type TestUser {
id: UUID
isActive: Boolean = true
createdAt: DateTime = DateTime.Now()
role: String = "user"
}
}
// In tests, you can override defaults
test DefaultTests for UserPolicy {
case "Test with default values" {
given {
user: {
id: "user-123"
// isActive will be true (default)
// role will be "user" (default)
}
resource: { id: "res-1" }
action: "read"
}
expect: ALLOW
}
case "Test overriding defaults" {
given {
user: {
id: "user-456",
isActive: false, // Override default
role: "admin" // Override default
}
resource: { id: "res-1" }
action: "read"
}
expect: DENY // Because isActive is false
}
}
Running Tests
Tests can be run via the PolicyFlow CLI:
# Run all tests
policyflow test
# Run tests for specific policy
policyflow test DocumentPolicy
# Run with coverage
policyflow test --coverage
# Run specific test file
policyflow test tests/document-access.pftest