PolicyFlow

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

  1. Unambiguous Types: If a type name is unique across all schemas, use it directly
  2. 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

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

Next Steps