Files

2657 lines
82 KiB
Plaintext

# APOPHIS Architecture (Revised)
## Overview
APOPHIS is a Fastify plugin that extends `@fastify/swagger` with contract-driven testing capabilities inspired by APOSTL (API PrOperty SpecificaTion Language) and PETIT (aPi tEsTIng Tool). It enables developers to write API contracts directly in their Fastify route schemas using OpenAPI custom properties (`x-*`), making the API self-documenting and self-testing.
This revision addresses header-sensitive APIs, safe hook ordering, escape hatches, formula safety, scope registries, and symbolic derivation of test properties from contracts.
## Core Concepts
### Design by Contract for APIs
APOPHIS brings Design by Contract principles to REST APIs through OpenAPI extensions:
- **Invariants** (`x-invariants`): Properties that must always hold across the entire API
- **Preconditions** (`x-requires`): Conditions that must hold before an operation executes
- **Postconditions** (`x-ensures`): Conditions that must hold after an operation executes
- **Data Generation** (`x-regex`): Regular expressions for generating valid test data
- **Category Override** (`x-category`): Override PETIT's default HTTP-method-based categorization
- **Header Access** (`request_headers(this).name`, `response_headers(this).name`): Access request/response headers in APOSTL formulas using property syntax. `request_headers(this).x-foo` checks incoming headers (preconditions), `response_headers(this).x-bar` checks outgoing headers (postconditions)
### Operation Categories
Following PETIT's methodology, operations are categorized for testing. APOPHIS uses **path-semantic inference** with HTTP method as fallback:
**Path-Semantic Rules** (checked in order):
- **Utility**: Paths containing `/reset`, `/health`, `/ping`, `/login`, `/logout`, `/auth`, `/callback`, `/purge`, `/clear`, `/initialize`, `/setup`, `/webhook`
- **Observer**: `GET` requests, paths ending with `/search`, `/count`, `/stats`, `/status`
- **Constructor**: `POST` to paths that look like collections (no path parameter after the resource name: `/players`, `/tournaments`)
- **Mutator**: `PUT`, `PATCH`, `DELETE`, or `POST` to specific resources (has path parameter: `/players/{id}`, `/tournaments/{id}/enrollments`)
**HTTP Method Fallback** (if no path-semantic match):
- **Constructors** (POST): Create resources
- **Mutators** (PUT, DELETE): Modify resources
- **Observers** (GET): Read resources without side effects
- **Utility** (arbitrary): Escape hatch for operations that don't fit
**Override with `x-category`:**
```yaml
paths:
/reset:
post:
x-category: utility
x-requires: [T]
x-ensures: [T]
```
This ensures `POST /reset` is automatically categorized as `utility` without requiring an explicit override, while `POST /players` correctly becomes a `constructor`.
## Architecture Components
### 1. Schema Extensions (`lib/schema-extensions.js`)
Extends Fastify's JSON Schema with APOSTL annotations:
```javascript
// Example route schema with APOSTL annotations
fastify.post('/players/:playerNIF', {
schema: {
description: 'Create a new player',
tags: ['players'],
// Preconditions: what must be true before execution (including headers)
'x-requires': [
'response_code(GET /players/{playerNIF}) == 404',
// Request header MUST be present (upset if missing)
'request_headers(this).x-tenant-id != null',
// Request header MUST have specific value
'request_headers(this).content-type == "application/json"'
],
// Postconditions: what must be true after execution (including headers)
'x-ensures': [
'response_code(GET /players/{playerNIF}) == 200',
'response_body(this) == request_body(this)',
// Response header postcondition
'response_headers(this).x-ledger-status == "finalized"'
],
params: {
type: 'object',
properties: {
playerNIF: {
type: 'string',
'x-regex': '(1|2)[0-9]{8}' // Data generation pattern
}
}
},
body: {
type: 'object',
properties: {
playerNIF: { type: 'string' },
firstName: { type: 'string' },
lastName: { type: 'string' },
email: { type: 'string', format: 'email' }
}
},
response: {
201: {
description: 'Player created',
type: 'object'
}
}
}
}, handler)
```
### 2. APOSTL Formula Parser (`lib/formula-parser.js`)
Parses and evaluates APOSTL formulas written in route schemas:
**Extended Grammar (APOSTL + Headers + Arbiter):**
```
formula ::= quantifiedFormula | booleanExpression | conditionalFormula
quantifiedFormula ::= quantifier string in call :- booleanExpression
quantifier ::= for | exists
conditionalFormula ::= if comparison then formula else formula
booleanExpression ::= booleanExpression booleanOperator booleanExpression | clause
clause ::= T | F | comparison
comparison ::= term comparator term
term ::= operation | operationPrevious | param
operationPrevious ::= previous(operation)
operation ::= operationHeader(operationParameter) function?
operationHeader ::= request_body | response_body | response_code | request_headers | response_headers | query_params | cookies | response_time
operationParameter ::= httpRequest | this
httpRequest ::= method url
comparator ::= == | != | <= | >= | < | > | matches
booleanOperator ::= && | || | =>
```
**Key Features:**
- Pure operations only (GET requests for conditions)
- `previous()` keyword to access state before operation
- `this` keyword to reference current request/response
- `request_headers(this).name` to reference incoming request headers
- `response_headers(this).name` to reference outgoing response headers
- `response_headers(GET /url).name` to reference headers from a GET request
- `query_params(this).name` to reference query parameters
- `query_params(GET /url).name` to reference query params from another request
- `cookies(this).name` to reference cookies
- `response_time(this)` to reference response time in milliseconds
- `response_time(GET /url)` to reference response time of another request
- `matches` comparator for regex matching
- Conditional formulas: `if condition then formula else formula`
- Quantifiers: `for` (universal), `exists` (existential)
### 3. Scope Registry (`lib/scope-registry.js`)
Manages tenant/application scope for header-sensitive APIs automatically:
```javascript
class ScopeRegistry {
constructor(options = {}) {
this.scopes = new Map()
this.defaultScope = {
tenantId: options.defaultTenantId || 'default',
applicationId: options.defaultApplicationId || 'service-a',
headers: options.defaultHeaders || {}
}
// Auto-discover scopes from environment
this.discoverFromEnvironment()
}
// Auto-discover scopes from environment variables
discoverFromEnvironment() {
// APOPHIS_SCOPE_tenant-a={"tenantId":"tenant-a",...}
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('APOPHIS_SCOPE_')) {
const scopeName = key.replace('APOPHIS_SCOPE_', '').toLowerCase()
try {
const config = JSON.parse(value)
this.register(scopeName, config)
} catch (e) {
console.warn(`Invalid APOPHIS scope config for ${scopeName}: ${e.message}`)
}
}
}
}
// Derive scope from request headers automatically
deriveFromRequest(request) {
const tenantId = request.headers['x-tenant-id']
const applicationId = request.headers['x-application-id']
if (!tenantId) return this.defaultScope
const scopeName = `${tenantId}-${applicationId || 'default'}`
if (!this.scopes.has(scopeName)) {
// Auto-register derived scope
this.register(scopeName, {
tenantId,
applicationId: applicationId || 'default',
headers: Object.fromEntries(
Object.entries(request.headers)
.filter(([k]) => k.startsWith('x-'))
)
})
}
return this.get(scopeName)
}
register(scopeName, scopeConfig) {
this.scopes.set(scopeName, {
tenantId: scopeConfig.tenantId,
applicationId: scopeConfig.applicationId,
headers: scopeConfig.headers || {},
auth: scopeConfig.auth || null
})
}
get(scopeName) {
return this.scopes.get(scopeName) || this.defaultScope
}
// Generate headers for a given scope
getHeaders(scopeName, overrides = {}) {
const scope = this.get(scopeName)
return {
'x-tenant-id': scope.tenantId,
'x-application-id': scope.applicationId,
...scope.headers,
...overrides
}
}
// Get auth header for scope
getAuth(scopeName) {
const scope = this.get(scopeName)
return scope.auth
}
}
// Usage in APOPHIS - automatic, no manual registration needed:
// Scopes auto-derived from requests or environment variables
// Optional: manually register for test setup
fastify.apophis.scope.register('tenant-a', {
tenantId: 'tenant-a',
applicationId: 'app-1'
})
```
### 4. Safe Hook Ordering (`lib/contract-validator.js`)
Validates API contracts at runtime with safe hook placement:
```javascript
// Validates preconditions before route handler
async function validatePreconditions(request, reply, routeSchema) {
const preconditions = routeSchema['x-requires']
if (!preconditions) return true
for (const condition of preconditions) {
const result = await evaluateFormula(condition, { request })
if (!result) {
throw new Error(`Precondition failed: ${condition}`)
}
}
return true
}
// Validates postconditions BEFORE serialization (onResponse, not onSend)
// Handles both body and header postconditions via unified APOSTL DSL
async function validatePostconditions(request, reply, routeSchema) {
const postconditions = routeSchema['x-ensures']
if (!postconditions) return true
for (const condition of postconditions) {
const result = await evaluateFormula(condition, { request, reply })
if (!result) {
throw new Error(`Postcondition failed: ${condition}`)
}
}
return true
}
```
**Hook Placement:**
```javascript
// SAFE: preHandler for preconditions (before handler)
// Validates x-requires including request_headers() conditions
fastify.addHook('preHandler', async (request, reply) => {
await validatePreconditions(request, reply, request.routeSchema)
})
// SAFE: onResponse for postconditions (after handler, before serialization)
// Validates x-ensures including response_headers() conditions
fastify.addHook('onResponse', async (request, reply) => {
await validatePostconditions(request, reply, request.routeSchema)
})
// UNSAFE: onSend may have already serialized the payload
// DO NOT USE: fastify.addHook('onSend', ...)
```
### 5. Formula Parameter Substitution (`lib/formula-substitutor.js`)
Safe parameter substitution with proper escaping and validation:
```javascript
class FormulaSubstitutor {
constructor() {
this.paramPattern = /\{([^}]+)\}/g
this.validationPattern = /^[a-zA-Z0-9_.-]+$/
}
// Safely substitute parameters in a formula
substitute(formula, params) {
if (!formula || typeof formula !== 'string') {
throw new Error('Formula must be a non-empty string')
}
return formula.replace(this.paramPattern, (match, paramName) => {
// Validate parameter name (prevent injection)
if (!this.validationPattern.test(paramName)) {
throw new Error(`Invalid parameter name: ${paramName}`)
}
// Get parameter value
const value = this.resolveParam(params, paramName)
if (value === undefined || value === null) {
throw new Error(`Missing parameter: ${paramName}`)
}
// Escape the value for safe insertion
return this.escapeValue(value)
})
}
// Resolve nested parameters (e.g., "t.tournamentId")
resolveParam(params, path) {
const parts = path.split('.')
let current = params
for (const part of parts) {
if (current === null || current === undefined) {
return undefined
}
current = current[part]
}
return current
}
// Escape values to prevent formula injection
escapeValue(value) {
if (typeof value === 'string') {
// Escape quotes and backslashes
return '"' + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
// For objects/arrays, serialize to JSON
return JSON.stringify(value)
}
// Validate that all required parameters are present
validateParams(formula, params) {
const required = new Set()
let match
while ((match = this.paramPattern.exec(formula)) !== null) {
required.add(match[1])
}
const missing = []
for (const param of required) {
if (this.resolveParam(params, param) === undefined) {
missing.push(param)
}
}
if (missing.length > 0) {
throw new Error(`Missing required parameters: ${missing.join(', ')}`)
}
return true
}
}
```
### 6. Cleanup Mechanism (`lib/cleanup-manager.js`)
Automatic resource cleanup with deterministic rollback:
```javascript
class CleanupManager {
constructor(fastify) {
this.fastify = fastify
this.createdResources = []
this.scope = null
this.autoCleanup = true
this.registered = false
}
// Set scope for cleanup operations
setScope(scopeName) {
this.scope = scopeName
}
// Automatically track resources from constructor responses
autoTrack(response, routeSchema) {
if (!this.autoCleanup) return
// Only track constructors (POST to collections)
const category = routeSchema?.['x-category'] || this.inferCategory(routeSchema)
if (category !== 'constructor') return
// Extract resource ID and URL from response
const resourceId = this.extractResourceId(response)
const deleteUrl = this.buildDeleteUrl(routeSchema.path, resourceId)
if (resourceId && deleteUrl) {
this.track(routeSchema.path, resourceId, deleteUrl)
}
}
extractResourceId(response) {
try {
const body = typeof response.payload === 'string'
? JSON.parse(response.payload)
: response.payload
// Common ID fields: id, uuid, tournamentId, playerNIF, etc.
return body?.id || body?.uuid || body?.tournamentId || body?.playerNIF
} catch {
return null
}
}
buildDeleteUrl(pathTemplate, resourceId) {
// Convert /players/:playerNIF to /players/123456789
return pathTemplate.replace(/:\w+/g, resourceId)
}
inferCategory(schema) {
// Path-semantic inference
const path = schema?.path || ''
if (/\/(reset|health|ping|login|logout|auth|callback|purge|clear|initialize|setup|webhook)/.test(path)) return 'utility'
if (schema?.method === 'GET') return 'observer'
if (schema?.method === 'POST' && !/\{\w+\}/.test(path)) return 'constructor'
return 'mutator'
}
// Track a created resource for later cleanup
track(resourceType, identifier, deleteUrl) {
this.createdResources.push({
type: resourceType,
id: identifier,
url: deleteUrl,
timestamp: Date.now()
})
}
// Cleanup all tracked resources in reverse order
async cleanup() {
const headers = this.scope
? this.fastify.apophis.scope.getHeaders(this.scope)
: {}
const errors = []
// Delete in reverse order (LIFO)
for (let i = this.createdResources.length - 1; i >= 0; i--) {
const resource = this.createdResources[i]
try {
await this.fastify.inject({
method: 'DELETE',
url: resource.url,
headers
})
} catch (error) {
errors.push({ resource, error: error.message })
}
}
this.createdResources = []
if (errors.length > 0) {
console.warn('Cleanup errors:', errors)
}
return errors
}
// Register cleanup on process exit (called automatically)
registerExitHandler() {
if (this.registered) return
this.registered = true
const cleanup = async () => {
await this.cleanup()
process.exit(0)
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
}
// Explicit cleanup call for manual control
async cleanup() {
return this.cleanup()
}
}
```
### 7. Test Data Generator (`lib/test-data-generator.js`)
Generates valid test data based on `x-regex` annotations with scope-aware headers:
```javascript
// Generates test data from regex patterns
function generateFromRegex(pattern) {
// Uses randexp or similar library
return randexp.generate(pattern)
}
// Generates complete request objects from schema
function generateTestData(schema, scope = null) {
const data = {}
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (propSchema['x-regex']) {
data[key] = generateFromRegex(propSchema['x-regex'])
} else if (propSchema.type === 'string') {
data[key] = generateRandomString(propSchema)
} else if (propSchema.type === 'integer') {
data[key] = generateRandomInteger(propSchema)
}
// ... handle other types
}
return data
}
// Generate headers for a request based on scope
function generateHeaders(schema, scopeName, scopeRegistry) {
const headers = scopeRegistry.getHeaders(scopeName)
// Extract required headers from x-requires formulas
// Pattern: request_headers(this).header-name
if (schema['x-requires']) {
for (const condition of schema['x-requires']) {
const headerMatches = condition.matchAll(/request_headers\(this\)\.([a-zA-Z0-9_-]+)/g)
for (const match of headerMatches) {
const headerName = match[1]
if (!headers[headerName]) {
// Generate default value if not in scope
headers[headerName] = generateDefaultHeader(headerName)
}
}
}
}
return headers
}
```
### 8. PETIT Test Runner (`lib/petit-runner.js`)
Automated testing engine with category override and scope support:
```javascript
class PetitRunner {
constructor(fastify, options = {}) {
this.fastify = fastify
this.strategy = options.strategy || 'CMO'
this.verbose = options.verbose || false
this.objectPool = new Map()
this.scope = options.scope || null
this.cleanupManager = new CleanupManager(fastify)
}
async run() {
const apis = this.discoverAPIs()
const results = []
try {
for (const api of apis) {
results.push(await this.testAPI(api))
}
} finally {
// Always cleanup, even on failure
await this.cleanupManager.cleanup()
}
return results
}
categorizeOperations(api) {
const categories = {
constructors: [],
mutators: [],
observers: [],
utility: []
}
for (const op of api.operations) {
// Check for x-category override first
const override = op.schema?.['x-category']
if (override) {
categories[override].push(op)
continue
}
// Default categorization by HTTP method
switch (op.method.toUpperCase()) {
case 'POST':
categories.constructors.push(op)
break
case 'PUT':
case 'DELETE':
categories.mutators.push(op)
break
case 'GET':
categories.observers.push(op)
break
default:
categories.utility.push(op)
}
}
return categories
}
async testOperation(operation) {
const headers = this.scope
? this.fastify.apophis.scope.getHeaders(this.scope)
: {}
// 1. Verify invariants
// 2. Generate or recycle test data
// 3. Verify preconditions (including header preconditions)
// 4. Execute request with proper headers
// 5. Verify postconditions (including header postconditions)
// 6. Track created resources for cleanup
// 7. Store results
}
}
```
### 9. Plugin Entry Point (`index.js`)
Main Fastify plugin registration with scope registry:
```javascript
const fp = require('fastify-plugin')
async function apophisPlugin(fastify, options) {
// Initialize scope registry
const scopeRegistry = new ScopeRegistry()
// Register with @fastify/swagger if not already registered
if (!fastify.swagger) {
await fastify.register(require('@fastify/swagger'), {
...options.swagger,
transform: apophisTransform
})
}
// Decorate fastify with APOPHIS utilities
fastify.decorate('apophis', {
// Scope registry for header-sensitive APIs (auto-derived + manual override)
scope: scopeRegistry,
// Generate OpenAPI with APOSTL annotations
spec: () => generateApophisSpec(fastify),
// Single test entry point: runs contract + property + stateful tests optimized
test: async (opts = {}) => {
const {
mode = 'all', // 'all' | 'contract' | 'property' | 'stateful'
depth = 'standard', // 'quick' | 'standard' | 'thorough'
...runnerOpts
} = opts
const config = {
quick: { contractRuns: 10, propertyRuns: 50, statefulRuns: 5, maxCommands: 10 },
standard: { contractRuns: 50, propertyRuns: 100, statefulRuns: 20, maxCommands: 30 },
thorough: { contractRuns: 200, propertyRuns: 1000, statefulRuns: 100, maxCommands: 50 }
}[depth]
const results = { mode, depth, contract: null, property: null, stateful: null }
const cleanupManager = new CleanupManager(fastify)
cleanupManager.setScope(runnerOpts.scope || null)
try {
if (mode === 'all' || mode === 'contract') {
results.contract = await new PetitRunner(fastify, {
...runnerOpts,
...config,
cleanupManager
}).run()
}
if (mode === 'all' || mode === 'property') {
results.property = await new ApophisPropertyTester(fastify, {
...runnerOpts,
...config,
cleanupManager
}).runPropertyTests()
}
if (mode === 'all' || mode === 'stateful') {
results.stateful = await new ApophisStatefulRunner(fastify, {
...runnerOpts,
...config,
cleanupManager
}).run()
}
} finally {
// Always cleanup, even on failure
await cleanupManager.cleanup()
}
return results
},
// Explicit cleanup for manual resource management
cleanup: async () => {
const cleanupManager = new CleanupManager(fastify)
return cleanupManager.cleanup()
},
// Validate a specific route's contracts
validate: (routePath) => validateRouteContracts(fastify, routePath),
// Generate test data for a route
generateTestData: (routePath) => generateRouteTestData(fastify, routePath)
})
// Add hooks for runtime contract validation (optional, with per-route opt-out)
if (options.validateRuntime !== false) {
// preHandler: validate preconditions before route handler
fastify.addHook('preHandler', async (request, reply) => {
// Per-route opt-out via x-validate-runtime: false
if (request.routeSchema?.['x-validate-runtime'] === false) return
await validatePreconditions(request, reply, request.routeSchema)
})
// onResponse: validate postconditions after handler, before serialization
fastify.addHook('onResponse', async (request, reply) => {
// Per-route opt-out via x-validate-runtime: false
if (request.routeSchema?.['x-validate-runtime'] === false) return
await validatePostconditions(request, reply, request.routeSchema)
})
}
}
module.exports = fp(apophisPlugin, {
name: 'apophis-fastify',
dependencies: ['@fastify/swagger']
})
```
## Data Flow
### 1. Schema Definition Flow
```
Developer writes route schema with x-* annotations
|
v
Fastify validates route schema (ignores x-* properties)
|
v
@fastify/swagger generates OpenAPI spec
|
v
apophisTransform preserves x-* annotations in output
|
v
OpenAPI spec contains self-documenting contracts
```
### 2. Runtime Validation Flow
```
Request arrives
|
v
preHandler hook: validate x-requires preconditions
|
v
Route handler executes
|
v
Handler sets response headers/body
|
v
onResponse hook: validate x-ensures postconditions
| (before serialization - safe!)
v
Serialization (onSend)
|
v
Response sent
```
### 3. Test Execution Flow (PETIT)
```
Developer calls fastify.apophis.test({ scope: 'tenant-a' })
|
v
Discover all routes with x-* annotations
|
v
Categorize: Constructors / Mutators / Observers / Utility
| (respecting x-category overrides)
v
Apply order strategy (e.g., CMO)
|
v
For each operation:
- Get headers from scope registry
- Verify API invariants
- Generate/recycle test data (using x-regex)
- Verify preconditions (x-requires including request_headers() conditions)
- Execute HTTP request with scope headers
- Verify postconditions (x-ensures including response_headers() conditions)
- Track created resources for cleanup
|
v
Cleanup: Remove all generated test data (reverse order)
|
v
Return test results
```
## OpenAPI Extensions Reference
### `x-invariants`
API-level invariants that must always hold:
```yaml
paths:
/tournaments:
x-invariants:
- for t in response_body(GET /tournaments) :-
response_body(GET /tournaments/{t.tournamentId}/enrollments).length <=
response_body(GET /tournaments/{t.tournamentId}/capacity)
```
### `x-requires`
Operation preconditions:
```yaml
paths:
/players/{playerNIF}:
delete:
x-requires:
- response_code(GET /players/{playerNIF}) == 200
```
### `x-ensures`
Operation postconditions:
```yaml
paths:
/players/{playerNIF}:
delete:
x-ensures:
- response_code(GET /players/{playerNIF}) == 404
- response_body(this) == previous(response_body(GET /players/{playerNIF}))
```
### Header Access in APOSTL Formulas
APOPHIS extends APOSTL with `request_headers` and `response_headers` accessors that use property syntax (via the `function` rule):
**Request headers** (available in preconditions and postconditions):
```yaml
x-requires:
# Request MUST have x-tenant-id header (upset if missing)
- request_headers(this).x-tenant-id != null
# Request content-type MUST be application/json
- request_headers(this).content-type == "application/json"
# Request MUST have either authorization OR txn-token
- request_headers(this).authorization != null || request_headers(this).txn-token != null
# Optional header: if present must be valid; if absent, ok
- request_headers(this).x-explain == null || request_headers(this).x-explain == "true"
```
**Response headers** (only available in postconditions):
```yaml
x-ensures:
# Response MUST include x-ledger-status header
- response_headers(this).x-ledger-status != null
# Response x-ledger-status MUST be "finalized"
- response_headers(this).x-ledger-status == "finalized"
# Response content-type MUST be application/json
- response_headers(this).content-type == "application/json"
```
**Header invariants** (must hold for ALL operations):
```yaml
x-invariants:
# Every request must have tenant identification
- request_headers(this).x-tenant-id != null
# Every response must include request ID for tracing
- response_headers(this).x-request-id != null
```
**Headers from other requests** (in quantifiers and comparisons):
```yaml
x-requires:
# Verify a GET request returns specific headers
- response_headers(GET /players/{playerNIF}).content-type == "application/json"
```
### Arbiter-Specific DSL Extensions
APOPHIS extends APOSTL with additional accessors needed for header-sensitive, multi-tenant APIs like Arbiter and Operator:
**Query Parameters:**
```yaml
x-requires:
# Pagination parameter must be present
- query_params(this).page != null
# Page size must be within limits
- query_params(this).limit <= 100
# Format override check
- query_params(this).format == null || query_params(this).format == "json" || query_params(this).format == "html"
x-ensures:
# Response respects requested format
- if query_params(this).format == "json" then response_headers(this).content-type == "application/json" else T
```
**Cookies:**
```yaml
x-requires:
# Session cookie must exist for authenticated endpoints
- cookies(this).session_id != null
# CSRF token validation
- cookies(this).csrf_token == request_headers(this).x-csrf-token
```
**Response Time (Performance Contracts):**
```yaml
x-ensures:
# Response must be fast enough
- response_time(this) < 500
# Rate limit check: if too fast, must be rate limited
- if response_time(GET /health) < 50 then response_headers(this).x-ratelimit-remaining != null else T
```
**Request Body of Other Operations:**
```yaml
x-ensures:
# Verify the POST body we sent matches what the GET returns
- response_body(GET /players/{playerNIF}) == request_body(POST /players)
```
**Regex Matching:**
```yaml
x-requires:
# Email format validation
- request_body(this).email matches "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
# UUID format check
- response_body(this).id matches "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
```
**Conditional Postconditions (Multi-step Flows):**
```yaml
x-ensures:
# Challenge flow: if challenge required, specific response; otherwise success
- if response_code(this) == 403 then response_body(this).challenge_type != null else response_code(this) == 200
# Tenant boundary: regular users get 403, admins bypass
- if request_headers(this).x-user-role == "admin" then response_code(this) == 200 else response_code(this) != 403
```
**State Transition Verification:**
```yaml
x-requires:
# Before MFA challenge, user must not be MFA-verified
- response_body(GET /users/{userId}).mfa_verified == false
x-ensures:
# After submitting MFA code, user is verified
- response_body(GET /users/{userId}).mfa_verified == true
# Previous state preserved for rollback check
- previous(response_body(GET /users/{userId}).mfa_verified) == false
```
### `x-regex`
Data generation patterns for properties:
```yaml
paths:
/players/{playerNIF}:
get:
parameters:
- name: playerNIF
schema:
type: string
x-regex: "(1|2)[0-9]{8}"
```
### `x-category`
Override PETIT's default categorization:
```yaml
paths:
/reset:
post:
x-category: utility
x-requires: [T]
x-ensures: [T]
```
Valid values: `constructor`, `mutator`, `observer`, `utility`
## Arbiter Auto-Testing Coverage
### What APOPHIS Can Auto-Test
With the extended DSL, APOPHIS can now automatically verify:
**1. Header Contract Compliance**
- Tenant boundary enforcement (`x-tenant-id` mismatch → 403)
- Token transport rules (ADR-052: txn-token in Authorization rejected)
- Required headers present (`x-application-id`, `content-type`)
- Response headers correct (`X-Ledger-Status`, `X-Request-Id`)
**2. Authentication Flows**
- Access token vs transaction token paths
- Missing auth → 401/403
- Invalid delegation tokens rejected
- S2S workload identity headers validated
**3. Challenge/Remediation Flows**
- Step-up authentication sequences
- MFA challenge → response with challenge options → MFA code → success
- WebAuthn assertion handling
- Conditional postconditions based on challenge state
**4. Rate Limiting**
- Response time checks (`response_time(this) < 500`)
- Rate limit headers present when expected (`X-RateLimit-Remaining`)
- 429 responses after threshold
**5. Tenant Isolation**
- Cross-tenant access blocked (JWT tenant != header tenant → 403)
- Admin bypass rules (`masterInternal`/`rootInternal` scopes)
- Graph store selection per scope
**6. Ledger Operations**
- Billing headers on metered endpoints
- Finalization status tracking
- Resource consumption reporting
**7. Content Negotiation**
- Accept header respected (`text/html` → HTML, `application/json` → JSON)
- Format query parameter overrides (`?format=markdown`)
- HTMX fragment responses (`HX-Request: true` → `html-fragment`)
### What Requires External Testing
Some Arbiter behaviors need specialized testing beyond contract DSL:
**1. Cryptographic Verification**
- HTTP Message Signature validation (`signature`, `signature-input`)
- Workload identity token proof verification
- Certificate chain validation
- *Why*: Requires crypto libraries, not expressible in HTTP contracts
**2. Database State Verification**
- Transaction isolation levels
- Deadlock detection
- Replication lag
- *Why*: Requires direct DB access, not API surface
**3. Infrastructure Behaviors**
- Load balancer health checks
- Graceful shutdown sequences
- Connection pool exhaustion
- *Why*: Requires system-level access
**4. Browser-Specific Flows**
- OAuth2 redirect flows (browser cookies, `sec-fetch-mode`)
- WebAuthn browser integration
- CORS preflight handling
- *Why*: Requires real browser or Playwright
**5. Eventual Consistency**
- Async ledger finalization timing
- Cache invalidation delays
- Graph store replication lag
- *Why*: Time-dependent, flaky in contract tests
**6. Security Penetration**
- SQL injection attempts
- XSS payload handling
- Path traversal
- *Why*: Requires adversarial input generation, not contract validation
### Recommended Test Pyramid for Arbiter
```
Top: Browser/Integration Tests (Playwright)
- OAuth flows
- HTMX interactions
- Full user journeys
Middle: APOPHIS Contract Tests (PETIT + fast-check)
- Header contracts
- Auth flows
- Challenge sequences
- Rate limiting
- Tenant isolation
- Ledger tracking
Bottom: Unit Tests + DB Tests
- Crypto verification
- DB transactions
- Business logic
```
## Testing Strategies
### Order Strategies
Following PETIT's approach:
- **COM**: Constructors → Observers → Mutators
- **CMO**: Constructors → Mutators → Observers
- **MCO**: Mutators → Constructors → Observers
- **MOC**: Mutators → Observers → Constructors
- **OCM**: Observers → Constructors → Mutators
- **OMC**: Observers → Mutators → Constructors
- **RND**: Random order
### Test Outcomes
| Preconditions | Response | Result |
|--------------|----------|--------|
| True | 200 OK | OK (if postconditions pass) |
| True | 4XX | Failed (analyze execution trace) |
| False | 200 | NOT OK (should have failed) |
| False | 4XX | Failed (as expected) |
## Stateful Testing Mechanics
Stateful testing in APOPHIS uses **model-based testing** with fast-check's `fc.modelBased` or command-based testing. The system generates random sequences of API operations, executes them against both a derived state machine model and the real API, and verifies they remain synchronized.
### Core Concepts
**Model State vs Real State**
- **Model State**: An in-memory abstraction tracking what "should" exist (derived from schema + invariants)
- **Real State**: The actual Fastify server state (database, cache, etc.)
- **Synchronization**: After each command, model predictions are verified against real API responses
**Commands**
Each API operation becomes a `Command` in the state machine:
```javascript
class ApiCommand {
constructor(operation, params, headers) {
this.operation = operation // Route schema + metadata
this.params = params // Generated parameters (path, query, body)
this.headers = headers // Scope headers + generated headers
this.category = operation.category // constructor | mutator | observer | utility
}
// Check if this command can run in current model state (preconditions)
check(modelState) {
// Evaluate x-requires against model state
return evaluatePreconditionsOnModel(this.operation['x-requires'], modelState, this.params)
}
// Run the command against the REAL API
async run(realState) {
const response = await realState.fastify.inject({
method: this.operation.method,
url: substituteParams(this.operation.path, this.params),
payload: this.params.body,
query: this.params.query,
headers: this.headers
})
return response
}
// Update the MODEL state based on command execution
runModel(modelState) {
// Apply state transition based on operation category and x-ensures
return applyModelTransition(modelState, this.operation, this.params, this.category)
}
// Fast-check shrinking: how to simplify this command
shrink() {
// Shrink parameters while maintaining schema validity
return shrinkParams(this.operation.schema, this.params)
}
}
```
### State Machine Definition
The state machine is derived from the API schema and invariants:
```javascript
class ApophisStateMachine {
constructor(fastify, options = {}) {
this.fastify = fastify
this.scope = options.scope || null
this.model = this.buildInitialModel()
this.commands = this.discoverCommands()
this.invariants = this.extractInvariants()
}
// Initial model state derived from schema structure
buildInitialModel() {
const model = new Map()
// Discover all resource collections from schemas
for (const [path, route] of Object.entries(this.fastify.getSchemas())) {
if (route.response?.[201] || route.response?.[200]) {
const resourceName = this.extractResourceName(path)
model.set(resourceName, {
items: new Map(),
relationships: new Map(),
counters: new Map()
})
}
}
return model
}
// Discover all possible commands from registered routes
discoverCommands() {
const commands = []
for (const route of this.fastify.routes) {
const category = route.schema?.['x-category'] || this.inferCategory(route.method)
const schema = route.schema || {}
// Build parameter arbitrary from schema
const paramsArb = this.buildParamsArbitrary(route)
commands.push({
name: `${route.method} ${route.path}`,
category,
path: route.path,
method: route.method,
schema,
paramsArb,
// Weight for random selection (observers more frequent than mutators)
weight: category === 'observer' ? 5 : category === 'constructor' ? 2 : 3
})
}
return commands
}
// Extract invariants from x-invariants declarations
extractInvariants() {
const invariants = []
for (const route of this.fastify.routes) {
if (route.schema?.['x-invariants']) {
invariants.push(...route.schema['x-invariants'])
}
}
// Also derive invariants from schema constraints
invariants.push(...this.deriveSchemaInvariants())
return invariants
}
}
```
### Command Generation Strategy
fast-check generates command sequences by:
1. **Filtering valid commands**: Only commands whose `check()` passes in current model state
2. **Category-aware generation**: Ensure resource exists before mutating it
3. **Biased selection**: Favor observers (safer) over constructors/mutators
```javascript
function generateCommandSequence(stateMachine, maxCommands = 50) {
return fc.array(
fc.constantFrom(...stateMachine.commands)
.chain(command => {
// Generate valid parameters for this command
return command.paramsArb.map(params => ({
...command,
params,
headers: stateMachine.scope
? stateMachine.fastify.apophis.scope.getHeaders(stateMachine.scope)
: {}
}))
})
.filter(command => {
// Only include commands that can run in current state
return command.check(stateMachine.model)
}),
{ minLength: 1, maxLength: maxCommands }
)
}
```
### Model-Real Synchronization Verification
After each command executes, verify the model prediction matches reality:
```javascript
async function verifySynchronization(modelState, realResponse, command) {
const discrepancies = []
// 1. Response code matches prediction
const predictedCode = predictResponseCode(modelState, command)
if (predictedCode !== realResponse.statusCode) {
discrepancies.push({
type: 'status_code_mismatch',
predicted: predictedCode,
actual: realResponse.statusCode
})
}
// 2. Response body structure matches schema
if (realResponse.statusCode < 400) {
const schema = command.schema.response?.[realResponse.statusCode]
if (schema) {
const validation = validateAgainstSchema(realResponse.json(), schema)
if (!validation.valid) {
discrepancies.push({
type: 'schema_violation',
errors: validation.errors
})
}
}
}
// 3. Invariants still hold in real state
for (const invariant of stateMachine.invariants) {
const holds = await evaluateInvariantOnReal(invariant, realResponse, command)
if (!holds) {
discrepancies.push({
type: 'invariant_violation',
invariant
})
}
}
// 4. Model-derived postconditions
if (command.schema?.['x-ensures']) {
for (const postcondition of command.schema['x-ensures']) {
const holds = await evaluatePostcondition(postcondition, realResponse, command)
if (!holds) {
discrepancies.push({
type: 'postcondition_failed',
condition: postcondition
})
}
}
}
return discrepancies
}
```
### Complete Stateful Test Runner
```javascript
class ApophisStatefulRunner {
constructor(fastify) {
this.fastify = fastify
this.cleanupManager = new CleanupManager(fastify)
}
async run(options = {}) {
const {
numRuns = 100,
maxCommands = 50,
scope = null,
seed = undefined // For deterministic replay
} = options
const stateMachine = new ApophisStateMachine(this.fastify, { scope })
return fc.assert(
fc.property(
generateCommandSequence(stateMachine, maxCommands),
async (commands) => {
// Setup: Start with empty state or seed state
await this.setupInitialState(stateMachine)
try {
for (const command of commands) {
// 1. Check preconditions on model
if (!command.check(stateMachine.model)) {
// Skip command if preconditions don't hold
continue
}
// 2. Execute on REAL API
const realResponse = await command.run({
fastify: this.fastify,
cleanupManager: this.cleanupManager
})
// 3. Execute on MODEL
const newModelState = command.runModel(stateMachine.model)
// 4. Verify synchronization
const discrepancies = await verifySynchronization(
newModelState, realResponse, command
)
if (discrepancies.length > 0) {
throw new StatefulTestError({
command: command.name,
params: command.params,
discrepancies,
commandHistory: commands.slice(0, commands.indexOf(command) + 1)
})
}
// 5. Update model state
stateMachine.model = newModelState
// 6. Track resources for cleanup
if (command.category === 'constructor') {
this.trackResourceForCleanup(command, realResponse)
}
}
} finally {
// Always cleanup
await this.cleanupManager.cleanup()
}
return true
}
),
{ numRuns, seed }
)
}
setupInitialState(stateMachine) {
// Reset model to initial state
stateMachine.model = stateMachine.buildInitialModel()
// Optionally seed with existing data
// (e.g., if testing against non-empty database)
}
trackResourceForCleanup(command, response) {
// Extract resource ID from response for later deletion
const resourceId = response.json()?.id || response.json()?.tournamentId
if (resourceId) {
this.cleanupManager.track(
command.operation.path,
resourceId,
command.operation.path.replace(/{.*?}/g, resourceId)
)
}
}
}
```
### Handling Stateful Test Failures
When a stateful test fails, APOPHIS provides detailed diagnostics:
```javascript
class StatefulTestError extends Error {
constructor({ command, params, discrepancies, commandHistory }) {
super(`Stateful test failed at command: ${command}`)
this.command = command
this.params = params
this.discrepancies = discrepancies
this.commandHistory = commandHistory
}
toString() {
let msg = `Stateful Test Failure\n`
msg += `===================\n\n`
msg += `Failed at command: ${this.command}\n`
msg += `Parameters: ${JSON.stringify(this.params, null, 2)}\n\n`
msg += `Command sequence (${this.commandHistory.length} commands):\n`
this.commandHistory.forEach((cmd, i) => {
msg += ` ${i + 1}. ${cmd.name} ${JSON.stringify(cmd.params)}\n`
})
msg += `\nDiscrepancies:\n`
this.discrepancies.forEach(d => {
msg += ` - [${d.type}]: ${JSON.stringify(d)}\n`
})
return msg
}
}
```
### Deterministic Replay
Failed stateful tests can be replayed exactly:
```javascript
// On failure, fast-check provides the seed and path
const failedRun = {
seed: 12345,
path: "0:1:2:3:4", // Which branches were taken
counterexample: [/* commands that failed */]
}
// Replay the exact same test
await runner.run({
seed: failedRun.seed,
// fast-check handles path replay internally
})
```
### Example: Tournament Stateful Test
```javascript
// Derived from the Tournament API schema
const tournamentStatefulTest = async (fastify) => {
const runner = new ApophisStatefulRunner(fastify)
return runner.run({
numRuns: 100,
maxCommands: 30,
scope: 'tenant-a'
})
}
// A failing test might produce:
// Stateful Test Failure
// ===================
// Failed at command: POST /tournaments/{tournamentId}/enrollments
// Parameters: { tournamentId: "550e8400-e29b-41d4-a716-446655440000", playerNIF: "123456789" }
//
// Command sequence (5 commands):
// 1. POST /tournaments { name: "Spring Cup", capacity: 2 }
// 2. POST /players { playerNIF: "123456789", ... }
// 3. POST /players { playerNIF: "987654321", ... }
// 4. POST /tournaments/{id}/enrollments { tournamentId: "550e...", playerNIF: "123456789" }
// 5. POST /tournaments/{id}/enrollments { tournamentId: "550e...", playerNIF: "987654321" }
// 6. POST /tournaments/{id}/enrollments { tournamentId: "550e...", playerNIF: "111111111" } <-- FAILED
//
// Discrepancies:
// - [postcondition_failed]: enrollments.length <= capacity
// Actual: 3 enrollments, capacity: 2
```
### Invariant Checking Across Command Sequences
Invariants are verified after EVERY command, not just at the end:
```javascript
async function verifyInvariants(stateMachine, modelState, realResponse) {
for (const invariant of stateMachine.invariants) {
// Check invariant on model state
const modelHolds = evaluateInvariantOnModel(invariant, modelState)
// Check invariant on real state via API calls
const realHolds = await evaluateInvariantOnReal(invariant, realResponse, stateMachine.fastify)
if (modelHolds !== realHolds) {
return {
type: 'invariant_divergence',
invariant,
modelHolds,
realHolds
}
}
if (!modelHolds || !realHolds) {
return {
type: 'invariant_violation',
invariant,
violatedIn: !modelHolds ? 'model' : 'real'
}
}
}
return null
}
```
### Integration with Schema-Driven Derivation
Stateful testing combines all previous layers:
1. **Schema-derived model structure** determines model state shape
2. **Schema-derived arbitraries** generate valid command parameters
3. **Derived preconditions** (`check()`) ensure commands are valid in current state
4. **Derived postconditions** verify real API behavior
5. **x-invariants** are checked after every command
6. **x-category** determines valid command transitions
This guarantees that stateful tests explore meaningful state spaces with valid data, catching bugs like:
- Race conditions in resource creation/deletion
- State machine violations (e.g., enrolling in full tournament)
- Missing cleanup or resource leaks
- Invariant violations across multi-step operations
## Symbolic Analysis: Deriving Properties from Contracts
Instead of requiring developers to write redundant `x-properties` and `x-stateful-test` annotations, APOPHIS derives property-based tests and stateful models directly from `x-requires`, `x-ensures`, and `x-invariants` using symbolic analysis.
### Three-Point Pipeline
```
Parser -> Validator -> Extractor
```
**1. Parser**: Parse APOSTL formulas into AST
**2. Validator**: Validate formula well-formedness and type consistency
**3. Extractor**: Extract fast-check properties and stateful models
### Example Derivation
Given:
```yaml
x-invariants:
- for t in response_body(GET /tournaments) :-
response_body(GET /tournaments/{t.tournamentId}/enrollments).length <=
response_body(GET /tournaments/{t.tournamentId}/capacity)
```
**Derived fast-check property:**
```javascript
fc.property(
fc.array(fc.record({
tournamentId: fc.integer(),
capacity: fc.integer({ min: 0 })
})),
fc.array(fc.record({
tournamentId: fc.integer(),
playerNIF: fc.stringMatching(/(1|2)[0-9]{8}/)
})),
(tournaments, enrollments) => {
// Group enrollments by tournament
const enrollmentCounts = {}
for (const e of enrollments) {
enrollmentCounts[e.tournamentId] = (enrollmentCounts[e.tournamentId] || 0) + 1
}
// Check invariant
for (const t of tournaments) {
const count = enrollmentCounts[t.tournamentId] || 0
if (count > t.capacity) return false
}
return true
}
)
```
**Derived stateful model:**
```javascript
class TournamentModel {
constructor() {
this.tournaments = new Map()
this.enrollments = new Map()
}
createTournament(id, capacity) {
this.tournaments.set(id, { id, capacity, enrollments: 0 })
}
enrollPlayer(tournamentId, playerNIF) {
const tournament = this.tournaments.get(tournamentId)
if (!tournament) return { success: false }
if (tournament.enrollments >= tournament.capacity) {
return { success: false }
}
tournament.enrollments++
return { success: true }
}
checkInvariant() {
for (const [id, tournament] of this.tournaments) {
if (tournament.enrollments > tournament.capacity) {
return false
}
}
return true
}
}
```
### QuickLogic Integration
APOPHIS can leverage QuickLogic3 from `~/Business/workspace/Operator/libs/QuickLogic` for advanced symbolic analysis:
```javascript
// lib/symbolic-analyzer.js
const { ESSContextQueryEngine } = require('~/Business/workspace/Operator/libs/QuickLogic/core/ContextRuntime')
const { NodeFactory } = require('~/Business/workspace/Operator/libs/QuickLogic/src/ast')
class ApophisSymbolicAnalyzer {
constructor() {
this.engine = new ESSContextQueryEngine()
this.parser = new APOSTLParser()
}
// Parse APOSTL formula into QuickLogic AST
parseToAST(formula) {
const apophisAST = this.parser.parse(formula)
return this.convertToQuickLogic(apophisAST)
}
// Convert APOSTL AST to QuickLogic PredicateApplication
convertToQuickLogic(ast) {
switch (ast.type) {
case 'comparison':
return NodeFactory.PredicateApplication({
predicate: 'equals',
args: [
this.convertToQuickLogic(ast.left),
this.convertToQuickLogic(ast.right)
]
})
case 'quantified':
return NodeFactory.ForAll({
variable: ast.variable,
body: this.convertToQuickLogic(ast.expression)
})
case 'operation':
return NodeFactory.PredicateApplication({
predicate: ast.header,
args: [ast.parameter]
})
default:
return ast
}
}
// Extract properties from contracts using QuickLogic inference
extractProperties(contracts) {
const properties = []
for (const contract of contracts) {
const ast = this.parseToAST(contract)
// Use QuickLogic to analyze the formula
const result = this.engine.query(ast)
if (result.truthValue === 'TRUE') {
// Extract property from proven contract
properties.push(this.generateProperty(ast))
} else if (result.truthValue === 'UNKNOWN') {
// Generate questions for unresolved parts
const questions = result.remainingQuestions
properties.push(...this.generatePropertiesFromQuestions(questions))
}
}
return properties
}
// Generate fast-check property from AST
generateProperty(ast) {
// Analyze AST structure to determine property type
if (this.isInvariant(ast)) {
return this.generateInvariantProperty(ast)
}
if (this.isPrecondition(ast)) {
return this.generatePreconditionProperty(ast)
}
if (this.isPostcondition(ast)) {
return this.generatePostconditionProperty(ast)
}
return null
}
// Generate stateful model from invariants
generateStatefulModel(invariants) {
const model = new Map()
for (const invariant of invariants) {
const ast = this.parseToAST(invariant)
// Extract collection and constraint
const collection = this.extractCollection(ast)
const constraint = this.extractConstraint(ast)
// Add to model
model.set(collection.name, {
type: 'collection',
constraint: constraint,
operations: this.deriveOperations(collection, constraint)
})
}
return model
}
}
```
## Implementation Phases
### Phase 1: Core Schema Extensions
- Implement `x-requires`, `x-ensures`, `x-invariants`, `x-regex`, `x-category` support
- Integrate with `@fastify/swagger` transform to preserve annotations
- Basic formula parser for simple comparisons
- Safe hook ordering (preHandler + onResponse, NOT onSend)
- Per-route opt-out via `x-validate-runtime: false`
### Phase 2: Header-Sensitive API Support
- Extend APOSTL grammar with `request_headers` and `response_headers` accessors
- Scope registry with auto-discovery from environment (`APOPHIS_SCOPE_*`)
- Automatic scope derivation from incoming request headers
- Header generation from `request_headers(this).x-foo` formulas
- Support for Arbiter/Operator header patterns
### Phase 3: Formula Safety
- Safe parameter substitution with validation and escaping
- Formula injection prevention
- Parameter completeness checking
### Phase 4: Cleanup Mechanism
- Automatic resource tracking from constructor responses
- Path-semantic inference for resource URL construction
- LIFO cleanup with scope-aware deletion
- Automatic cleanup on test completion / process exit
- Explicit `cleanup()` API for manual control
### Phase 5: PETIT Test Runner
- Path-semantic operation categorization (automatic utility detection)
- HTTP method fallback categorization
- `x-category` override support
- Strategy-based ordering
- Scope-aware test execution
- Single `test()` entry point with mode selection
### Phase 6: Symbolic Analysis
- Parser-Validator-Extractor three-point pipeline
- Derive fast-check properties from contracts
- Derive stateful models from invariants
- QuickLogic3 integration for advanced inference
### Phase 7: fast-check Integration
- Schema-to-arbitrary conversion
- Automatic edge case discovery
- Shrinking support for minimal counterexamples
- Named arbitraries for better error messages
### Phase 8: Stateful Testing
- Model-based stateful testing with derived models
- Command registration and discovery from schemas
- Invariant checking across command sequences
- Model-API synchronization verification
- Deterministic replay with seed support
### Phase 9: DX Polish
- Single `test()` with `mode` and `depth` options
- Automatic cleanup integration
- Per-route opt-out support
- Environment-based scope configuration
- Path-semantic category inference
- Better error messages with derived context
## File Structure
```
apophis-fastify/
├── lib/
│ ├── formula-parser.js # APOSTL formula parsing and evaluation
│ ├── formula-substitutor.js # Safe parameter substitution
│ ├── schema-extensions.js # Schema validation and extension handling
│ ├── openapi-transform.js # Transform for @fastify/swagger integration
│ ├── contract-validator.js # Runtime contract validation (safe hooks)
│ ├── test-data-generator.js # Test data generation from x-regex
│ ├── petit-runner.js # PETIT automated test runner
│ ├── cleanup-manager.js # Resource cleanup and rollback
│ ├── scope-registry.js # Tenant/application scope management
│ ├── symbolic-analyzer.js # Derive properties from contracts
│ ├── fast-check-integration.js # Property-based testing with fast-check
│ ├── stateful-test-runner.js # Model-based stateful testing
│ └── utils.js # Shared utilities
├── models/ # Built-in stateful testing models
│ └── tournament-model.js # Example: Tournament domain model
├── index.js # Main plugin entry point
├── package.json
└── README.md
```
## Dependencies
- `@fastify/swagger`: Core swagger integration
- `fastify-plugin`: Plugin wrapper for Fastify
- `randexp`: Regex-based string generation (optional)
- `fast-check`: Property-based testing framework (optional)
- `ajv`: JSON Schema validation (peer dependency via Fastify)
- `quicklogic3`: Symbolic analysis engine (optional, from Operator/libs/QuickLogic)
## Usage Example
```javascript
const fastify = require('fastify')()
// Register APOPHIS (registers @fastify/swagger automatically)
await fastify.register(require('apophis-fastify'), {
swagger: {
openapi: '3.0.0',
info: { title: 'Tournaments API', version: '1.0.0' }
},
validateRuntime: true // Enable runtime contract validation globally
})
// Scopes auto-derived from requests, but can be pre-registered for testing
fastify.apophis.scope.register('tenant-a', {
tenantId: 'tenant-a',
applicationId: 'app-1'
})
// Define routes with contracts
fastify.post('/players/:playerNIF', {
schema: {
'x-requires': [
'response_code(GET /players/{playerNIF}) == 404',
// Request header MUST be present (upset if missing)
'request_headers(this).x-tenant-id != null',
// Request header MUST have specific value
'request_headers(this).content-type == "application/json"'
],
'x-ensures': [
'response_code(GET /players/{playerNIF}) == 200',
'response_body(this) == request_body(this)',
// Response header postcondition
'response_headers(this).x-ledger-status == "finalized"'
],
params: {
type: 'object',
properties: {
playerNIF: {
type: 'string',
'x-regex': '(1|2)[0-9]{8}'
}
}
}
}
}, async (request, reply) => {
// Handler implementation
})
// POST /reset auto-categorized as utility (path-semantic inference)
// No x-category override needed!
fastify.post('/reset', {
schema: {
'x-requires': [T],
'x-ensures': [T]
}
}, async (request, reply) => {
// Reset implementation
})
// Opt-out of runtime validation for specific routes
fastify.get('/health', {
schema: {
'x-validate-runtime': false, // Skip contract validation
response: {
200: {
type: 'object',
properties: {
status: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
return { status: 'ok' }
})
// Generate OpenAPI spec with contracts
const spec = fastify.apophis.spec()
console.log(JSON.stringify(spec, null, 2))
// Single test entry point: runs contract + property + stateful tests
const results = await fastify.apophis.test({
mode: 'all', // 'all' | 'contract' | 'property' | 'stateful'
depth: 'standard', // 'quick' | 'standard' | 'thorough'
scope: 'tenant-a',
verbose: true
})
// Results structure:
// {
// mode: 'all',
// depth: 'standard',
// contract: { passed: 45, failed: 2, ... },
// property: { passed: 100, failed: 0, ... },
// stateful: { passed: 20, failed: 1, sequences: [...] }
// }
// Automatic cleanup runs after tests complete (or on failure)
// Explicit cleanup if needed:
await fastify.apophis.cleanup()
await fastify.listen({ port: 3000 })
```
## Schema-Driven Derivation
APOPHIS extracts maximal testing value from standard JSON Schema and OpenAPI constructs. The `x-*` annotations provide semantic intent, but the schema itself defines the shape, constraints, and relationships of data. By driving fast-check arbitrary generation, implicit precondition/postcondition derivation, and stateful model structure directly from standard schema properties, we guarantee that generated tests are type-safe, boundary-aware, and semantically valid even when no explicit contracts are written.
### Schema-to-Arbitrary Mapping
Every JSON Schema property maps directly to a `fast-check` arbitrary. This ensures generated test data respects the API's own validation rules.
| JSON Schema Property | fast-check Arbitrary | Notes |
|---|---|---|
| `type: string` | `fc.string()` | Base arbitrary for strings |
| `minLength: n` | `.filter(s => s.length >= n)` | Applied after generation |
| `maxLength: n` | `fc.string({ maxLength: n })` | Direct fast-check support |
| `pattern: "regex"` | `fc.stringMatching(/regex/)` | Standard JSON Schema pattern (not just `x-regex`) |
| `format: email` | `fc.emailAddress()` | Semantic format → semantic arbitrary |
| `format: uuid` | `fc.uuid()` | v4 UUID generation |
| `format: date-time` | `fc.date().map(d => d.toISOString())` | ISO 8601 strings |
| `format: uri` | `fc.webUrl()` | Valid URL generation |
| `enum: ["a", "b"]` | `fc.constantFrom("a", "b")` | Exhaustive or sampled |
| `type: integer` | `fc.integer()` | Whole numbers |
| `minimum: 0` | `fc.integer({ min: 0 })` | Lower bound |
| `maximum: 100` | `fc.integer({ max: 100 })` | Upper bound |
| `exclusiveMinimum: true` + `minimum: 0` | `fc.integer({ min: 1 })` | Exclusive bounds |
| `multipleOf: 5` | `fc.integer().map(n => n * 5)` | Step values |
| `type: number` | `fc.float()` | Floating point |
| `type: boolean` | `fc.boolean()` | True/false |
| `type: array` | `fc.array(itemArb)` | Collection arbitrary |
| `minItems: 1` | `fc.array(itemArb, { minLength: 1 })` | Non-empty arrays |
| `maxItems: 10` | `fc.array(itemArb, { maxLength: 10 })` | Bounded arrays |
| `uniqueItems: true` | `.filter(arr => new Set(arr).size === arr.length)` | Set semantics |
| `type: object` + `properties` | `fc.record({ ... })` | Structured objects |
| `required: ["a", "b"]` | All required keys always present in record | Schema enforcement |
| `additionalProperties: false` | `fc.record({ ... }, { withDeletedKeys: false })` | Strict shape |
| `nullable: true` | `fc.option(arb, { nil: null })` | Null distribution |
| `default: "x"` | Bias toward default value in arbitrary | Edge case coverage |
| `readOnly: true` | Excluded from request body generation | Client cannot set |
| `writeOnly: true` | Excluded from response validation arbitrary | Server-only field |
**Implementation (`lib/schema-to-arbitrary.js`):**
```javascript
const fc = require('fast-check')
class SchemaToArbitrary {
convert(schema, context = 'request') {
if (schema.$ref) {
return this.resolveRef(schema.$ref)
}
// Handle nullable: type can be ["string", "null"] or nullable: true
const isNullable = schema.nullable === true ||
(Array.isArray(schema.type) && schema.type.includes('null'))
const baseArb = this.convertBaseType(schema, context)
if (isNullable) {
return fc.option(baseArb, { nil: null, freq: 5 })
}
return baseArb
}
convertBaseType(schema, context) {
const type = Array.isArray(schema.type)
? schema.type.find(t => t !== 'null')
: schema.type
switch (type) {
case 'string':
return this.stringArbitrary(schema)
case 'integer':
return this.integerArbitrary(schema)
case 'number':
return this.numberArbitrary(schema)
case 'boolean':
return fc.boolean()
case 'array':
return this.arrayArbitrary(schema, context)
case 'object':
return this.objectArbitrary(schema, context)
default:
// Union types (anyOf, oneOf)
if (schema.anyOf) {
return fc.oneof(...schema.anyOf.map(s => this.convert(s, context)))
}
if (schema.oneOf) {
return fc.oneof(...schema.oneOf.map(s => this.convert(s, context)))
}
return fc.anything()
}
}
stringArbitrary(schema) {
// format takes precedence over pattern
if (schema.format) {
switch (schema.format) {
case 'email': return fc.emailAddress()
case 'uuid': return fc.uuid()
case 'date-time': return fc.date().map(d => d.toISOString())
case 'date': return fc.date().map(d => d.toISOString().split('T')[0])
case 'uri': return fc.webUrl()
case 'hostname': return fc.domain()
case 'ipv4': return fc.ipV4()
case 'ipv6': return fc.ipV6()
}
}
if (schema.pattern) {
return fc.stringMatching(new RegExp(schema.pattern))
}
if (schema.x-regex) {
return fc.stringMatching(new RegExp(schema['x-regex']))
}
// Handle length constraints
const minLength = schema.minLength || 0
const maxLength = schema.maxLength || 100
if (minLength === maxLength) {
return fc.string({ minLength, maxLength })
}
return fc.string({ minLength, maxLength })
}
integerArbitrary(schema) {
const min = schema.minimum !== undefined ? schema.minimum : Number.MIN_SAFE_INTEGER
const max = schema.maximum !== undefined ? schema.maximum : Number.MAX_SAFE_INTEGER
// Handle exclusive bounds
const effectiveMin = schema.exclusiveMinimum ? min + 1 : min
const effectiveMax = schema.exclusiveMaximum ? max - 1 : max
let arb = fc.integer({ min: effectiveMin, max: effectiveMax })
if (schema.multipleOf) {
arb = arb.map(n => Math.round(n / schema.multipleOf) * schema.multipleOf)
.filter(n => n >= effectiveMin && n <= effectiveMax)
}
return arb
}
numberArbitrary(schema) {
const min = schema.minimum !== undefined ? schema.minimum : -1e308
const max = schema.maximum !== undefined ? schema.maximum : 1e308
let arb = fc.float({ min, max })
if (schema.multipleOf) {
arb = arb.map(n => Math.round(n / schema.multipleOf) * schema.multipleOf)
}
return arb
}
arrayArbitrary(schema, context) {
const itemArb = schema.items
? this.convert(schema.items, context)
: fc.anything()
const minItems = schema.minItems || 0
const maxItems = schema.maxItems || 10
let arb = fc.array(itemArb, { minLength: minItems, maxLength: maxItems })
if (schema.uniqueItems) {
arb = arb.filter(arr => new Set(arr).size === arr.length)
}
return arb
}
objectArbitrary(schema, context) {
const properties = {}
const required = new Set(schema.required || [])
for (const [key, propSchema] of Object.entries(schema.properties || {})) {
// Skip readOnly properties in request context, writeOnly in response context
if (context === 'request' && propSchema.readOnly) continue
if (context === 'response' && propSchema.writeOnly) continue
properties[key] = this.convert(propSchema, context)
}
// Handle additionalProperties
const withDeletedKeys = schema.additionalProperties === false
? false
: true
return fc.record(properties, {
withDeletedKeys,
requiredKeys: Array.from(required)
})
}
resolveRef(refPath) {
// Resolve $ref against OpenAPI components/schemas
return this.refResolver.resolve(refPath)
}
}
```
### Implicit Precondition Derivation
Standard schema properties encode preconditions that APOPHIS can extract automatically. These augment explicit `x-requires` annotations.
| Schema Property | Derived Precondition | APOSTL Formula Equivalent |
|---|---|---|
| `required: ["field"]` | Field must be present in request | `request_body(this).field != null` |
| `type: string` on param | Parameter must be string | `typeof request_body(this).field == "string"` |
| `format: email` | Must match email format | `request_body(this).field matches "^[^@]+@[^@]+$"` |
| `minimum: 0` | Must be non-negative | `request_body(this).field >= 0` |
| `enum: ["a", "b"]` | Must be one of allowed values | `request_body(this).field == "a" \|\| request_body(this).field == "b"` |
| `readOnly: true` on property | Must NOT be present in request | `request_body(this).field == null` |
| `maxLength: 255` | Must not exceed length | `request_body(this).field.length <= 255` |
| `pattern: "^[A-Z]+$"` | Must match pattern | `request_body(this).field matches "^[A-Z]+$"` |
**Example - Full Schema with Derived Preconditions:**
```yaml
paths:
/players/{playerNIF}:
post:
parameters:
- name: playerNIF
in: path
required: true
schema:
type: string
pattern: "^(1|2)[0-9]{8}$"
requestBody:
content:
application/json:
schema:
type: object
required: [firstName, lastName, email]
properties:
firstName:
type: string
minLength: 1
maxLength: 100
lastName:
type: string
minLength: 1
maxLength: 100
email:
type: string
format: email
age:
type: integer
minimum: 0
maximum: 150
nullable: true
role:
type: string
enum: [player, coach, referee]
default: player
createdAt:
type: string
format: date-time
readOnly: true
```
**Derived preconditions (automatically added to `x-requires`):**
```javascript
// From path parameter
'request_body(this).playerNIF matches "^(1|2)[0-9]{8}$"'
// From required fields
'request_body(this).firstName != null'
'request_body(this).lastName != null'
'request_body(this).email != null'
// From type constraints
'typeof request_body(this).firstName == "string"'
'typeof request_body(this).age == "integer" || request_body(this).age == null'
// From format constraints
'request_body(this).email matches "^[^@]+@[^@]+$"'
// From length constraints
'request_body(this).firstName.length >= 1'
'request_body(this).firstName.length <= 100'
// From numeric bounds
'request_body(this).age >= 0 || request_body(this).age == null'
'request_body(this).age <= 150 || request_body(this).age == null'
// From enum
'request_body(this).role == "player" || request_body(this).role == "coach" || request_body(this).role == "referee"'
// From readOnly (MUST NOT be sent by client)
'request_body(this).createdAt == null'
```
### Implicit Postcondition Derivation
Response schemas implicitly define postconditions about the shape and constraints of returned data.
| Schema Property | Derived Postcondition | APOSTL Formula Equivalent |
|---|---|---|
| `type: object` with `properties` | Response has expected shape | `response_body(this).field != null` (for required) |
| `readOnly: true` | Field preserved from creation | `response_body(this).field == previous(response_body(this).field)` |
| `format: uuid` on response | Returned ID is valid UUID | `response_body(this).id matches "^[0-9a-f-]{36}$"` |
| `nullable: false` | Field must be present | `response_body(this).field != null` |
| `writeOnly: true` | Field must NOT appear in response | `response_body(this).password == null` |
| `default: "x"` | If not set, defaults to x | `response_body(this).status == "x"` (when not explicitly set) |
### Model Structure Derivation
The schema topology itself defines the stateful model's structure, resource graph, and valid state transitions.
**Resource Graph from Schema Relationships:**
```yaml
components:
schemas:
Tournament:
type: object
properties:
tournamentId:
type: string
format: uuid
readOnly: true
name:
type: string
capacity:
type: integer
minimum: 1
enrollments:
type: array
items:
$ref: '#/components/schemas/Enrollment'
readOnly: true
organizer:
$ref: '#/components/schemas/Player'
Enrollment:
type: object
properties:
playerNIF:
type: string
pattern: "^(1|2)[0-9]{8}$"
tournamentId:
type: string
format: uuid
status:
type: string
enum: [pending, confirmed, cancelled]
default: pending
```
**Derived stateful model:**
```javascript
class TournamentModel {
constructor() {
// From schema properties + types
this.tournaments = new Map() // tournamentId -> Tournament
this.enrollments = new Map() // compositeKey -> Enrollment
this.players = new Map() // playerNIF -> Player
// Track relationships implied by schema structure
this.tournamentEnrollments = new Map() // tournamentId -> Set<playerNIF>
}
// Constructor derived from POST /tournaments schema
createTournament(name, capacity, organizerNIF) {
// Implicit precondition: capacity >= 1 (from minimum: 1)
if (capacity < 1) return { success: false, error: 'capacity < 1' }
// Implicit precondition: organizer must exist (from $ref relationship)
if (!this.players.has(organizerNIF)) return { success: false, error: 'organizer not found' }
const tournamentId = generateUUID() // from format: uuid
this.tournaments.set(tournamentId, {
tournamentId,
name,
capacity,
organizer: organizerNIF,
enrollments: [], // derived from array property
status: 'active'
})
this.tournamentEnrollments.set(tournamentId, new Set())
return { success: true, tournamentId }
}
// Mutator derived from POST /tournaments/{id}/enrollments
enrollPlayer(tournamentId, playerNIF) {
// Implicit precondition: tournament exists (from path param reference)
if (!this.tournaments.has(tournamentId)) return { success: false, error: 'tournament not found' }
// Implicit precondition: player exists (from $ref: '#/components/schemas/Player')
if (!this.players.has(playerNIF)) return { success: false, error: 'player not found' }
const tournament = this.tournaments.get(tournamentId)
const currentEnrollments = this.tournamentEnrollments.get(tournamentId)
// Invariant derived from x-invariants or schema constraints
if (currentEnrollments.size >= tournament.capacity) {
return { success: false, error: 'tournament full' }
}
// Implicit postcondition: enrollment status defaults to "pending"
const enrollment = {
playerNIF,
tournamentId,
status: 'pending' // from default: pending
}
this.enrollments.set(`${tournamentId}:${playerNIF}`, enrollment)
currentEnrollments.add(playerNIF)
tournament.enrollments.push(enrollment)
return { success: true, enrollment }
}
// Observer derived from GET /tournaments/{id}
getTournament(tournamentId) {
return this.tournaments.get(tournamentId) || null
}
// Invariant check derived from schema + x-invariants
checkInvariants() {
for (const [id, tournament] of this.tournaments) {
// Schema-derived: capacity must be >= 1
if (tournament.capacity < 1) return false
// Schema-derived: tournamentId must be UUID format
if (!isValidUUID(tournament.tournamentId)) return false
// x-invariant-derived: enrollments <= capacity
const enrollmentCount = this.tournamentEnrollments.get(id).size
if (enrollmentCount > tournament.capacity) return false
}
return true
}
}
```
### Semantic Hint Extraction
Descriptions and examples provide testing guidance:
| Source | Extraction | Testing Use |
|---|---|---|
| `description: "Player's fiscal identification number"` | Field semantic meaning | Better error messages, named arbitraries |
| `example: "123456789"` | Concrete valid value | Seed for fast-check, baseline test case |
| `description: "Must be unique within tournament"` | Uniqueness constraint | Implicit postcondition / model invariant |
| `description: "Automatically set on creation"` | readOnly semantic | Skip in request generation |
**Named Arbitraries from Descriptions:**
```javascript
// Instead of anonymous fc.string(), generate named arbitraries:
fc.string().map(s => ({ value: s, _name: 'playerNIF' }))
// This enables better shrinking messages:
// "Property failed with playerNIF = 'abc' (must match /^(1|2)[0-9]{8}$/)"
```
### Integration: Layered Derivation
APOPHIS combines standard schema derivation with explicit APOSTL contracts in layers:
```
Layer 1: Standard JSON Schema
→ Type-safe arbitrary generation
→ Basic preconditions (required, type, format)
→ Model structure ($ref, array relationships)
Layer 2: x-regex annotations
→ Refined arbitrary generation (regex instead of format)
→ Edge case generation (boundary values from regex)
Layer 3: x-requires / x-ensures
→ Semantic pre/postconditions
→ Cross-resource conditions
→ Header and state conditions
Layer 4: x-invariants
→ Model invariants
→ Stateful property generation
Layer 5: x-category
→ Operation classification for test ordering
```
**Complete Example - All Layers Combined:**
```yaml
paths:
/tournaments/{tournamentId}/enrollments:
post:
x-category: constructor
x-requires:
# Layer 3: Semantic preconditions
- response_body(GET /tournaments/{tournamentId}) != null
x-ensures:
# Layer 3: Semantic postconditions
- response_code(this) == 201
- response_body(this).status == "pending"
parameters:
- name: tournamentId
in: path
required: true
schema:
type: string
format: uuid # Layer 1: Type constraint
requestBody:
content:
application/json:
schema:
type: object
required: [playerNIF] # Layer 1: Required field
properties:
playerNIF:
type: string
x-regex: "(1|2)[0-9]{8}" # Layer 2: Refined generation
description: "Player fiscal ID"
notes:
type: string
maxLength: 500 # Layer 1: Length constraint
nullable: true
```
**Derived test artifacts:**
```javascript
// Layer 1: Standard schema arbitrary
const enrollmentRequestArb = fc.record({
playerNIF: fc.string(), // Base type constraint
notes: fc.option(fc.string({ maxLength: 500 }), { nil: null })
}, {
requiredKeys: ['playerNIF'] // required field enforcement
})
// Layer 2: x-regex refines the arbitrary
const refinedArb = fc.record({
playerNIF: fc.stringMatching(/(1|2)[0-9]{8}/), // Refined by x-regex
notes: fc.option(fc.string({ maxLength: 500 }), { nil: null })
}, {
requiredKeys: ['playerNIF']
})
// Layer 1+3: Derived preconditions
const preconditions = [
'request_body(this).playerNIF != null', // from required
'typeof request_body(this).playerNIF == "string"', // from type
'request_body(this).notes.length <= 500 || request_body(this).notes == null', // from maxLength + nullable
'response_body(GET /tournaments/{tournamentId}) != null' // from x-requires
]
// Layer 1+3: Derived postconditions
const postconditions = [
'response_code(this) == 201', // from x-ensures
'response_body(this).status == "pending"', // from x-ensures
'typeof response_body(this).playerNIF == "string"', // from response schema type
'response_body(this).playerNIF matches "^(1|2)[0-9]{8}$"' // from x-regex on response
]
// Layer 4+5: Derived stateful model
class EnrollmentStatefulModel {
constructor() {
this.tournaments = new Map()
this.enrollments = new Map() // tournamentId -> Map<playerNIF, enrollment>
}
createEnrollment(tournamentId, playerNIF, notes) {
// Layer 3: Explicit precondition
if (!this.tournaments.has(tournamentId)) return { success: false }
// Layer 1: Schema-derived model structure
const enrollment = {
playerNIF,
notes,
status: 'pending', // from x-ensures
tournamentId
}
if (!this.enrollments.has(tournamentId)) {
this.enrollments.set(tournamentId, new Map())
}
this.enrollments.get(tournamentId).set(playerNIF, enrollment)
return { success: true, enrollment }
}
}
// fast-check property combining all layers
fc.assert(
fc.property(
fc.uuid(), // tournamentId from format: uuid
fc.stringMatching(/(1|2)[0-9]{8}/), // playerNIF from x-regex
fc.option(fc.string({ maxLength: 500 }), { nil: null }), // notes from schema
(tournamentId, playerNIF, notes) => {
// Execute API call (injected request)
// Verify all preconditions + postconditions
// Check model synchronization
}
)
)
```
### Guarantees of Schema-Driven Derivation
By deriving from standard schema:
1. **No false positives from type mismatches**: Generated data always passes Fastify's own JSON Schema validation
2. **Boundary coverage**: `minimum`, `maximum`, `minLength`, `maxLength` ensure edge cases are tested
3. **Semantic validity**: `format`, `pattern`, `enum` guarantee data makes business sense
4. **Relationship integrity**: `$ref`, array `items` build correct resource graphs in models
5. **Reduced annotation burden**: APIs with good schemas need fewer `x-*` annotations to be fully testable
6. **Schema drift detection**: If schema changes, derived tests automatically adapt
## Future Extensions
- **RAML Support**: Extend beyond OpenAPI to RAML specifications
- **GraphQL**: Adapt contract concepts to GraphQL schemas
- **Mutation Testing**: Verify contract completeness
- **Performance Contracts**: Add latency and throughput requirements
- **Visual Testing**: Contract-based screenshot comparison for UI APIs
- **Chaos Engineering**: Inject failures based on contract boundaries
## References
- Ribeiro, A.C.M. (2021). "Invariant-Driven Automated Testing". MSc Thesis, NOVA University of Lisbon.
- APOSTL: API PrOperty SpecificaTion Language
- PETIT: aPi tEsTIng Tool
- OpenAPI Specification v3.0
- Fastify Plugin Architecture
- Design by Contract (Meyer, 1988)
- Hoare Logic (Hoare, 1969)
- QuickLogic3: Contextual Four-Valued Reasoning Runtime