2657 lines
82 KiB
Plaintext
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
|