@detiq/api-tests 0.0.0-stage → 1.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +24 -0
- package/ci/github-actions.yml +127 -0
- package/ci/gitlab-ci.yml +29 -0
- package/ci/test-result-schema.ts +55 -0
- package/package.json +16 -4
- package/playwright.config.ts +25 -0
- package/src/lib/api-client.ts +82 -0
- package/src/lib/assertions.ts +79 -0
- package/src/lib/cleanup-registry.ts +36 -0
- package/src/lib/environment-guard.ts +46 -0
- package/src/lib/retry.ts +53 -0
- package/src/specs/auth.api.spec.ts +112 -0
- package/src/specs/smoke.api.spec.ts +28 -0
- package/tsconfig.json +20 -0
- package/README.md +0 -3
package/.env.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# API Testing Environment Configuration
|
|
2
|
+
# Copy to .env.test and fill in values for your environment.
|
|
3
|
+
|
|
4
|
+
# Base URL of the API under test
|
|
5
|
+
API_TEST_BASE_URL=https://staging.api.example.com
|
|
6
|
+
|
|
7
|
+
# Environment identifier — used by EnvironmentGuard to block MUTATING tests on prod
|
|
8
|
+
# Values: dev | staging | qa | prod | production
|
|
9
|
+
API_TEST_ENV=staging
|
|
10
|
+
|
|
11
|
+
# Static bearer token for bearer_static auth profile tests
|
|
12
|
+
API_TEST_BEARER_TOKEN=
|
|
13
|
+
|
|
14
|
+
# API key for api_key auth profile tests
|
|
15
|
+
API_TEST_API_KEY=
|
|
16
|
+
|
|
17
|
+
# Password for basic or jwt_login auth profile tests
|
|
18
|
+
API_TEST_PASSWORD=
|
|
19
|
+
|
|
20
|
+
# Set to 'true' to enable LOAD_SENSITIVE tests (rate limit, concurrency)
|
|
21
|
+
API_TEST_ENABLE_LOAD=false
|
|
22
|
+
|
|
23
|
+
# Set to 'true' to allow MUTATING tests to run on production (requires explicit approval)
|
|
24
|
+
API_TEST_ALLOW_MUTATING_ON_PROD=false
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
name: API Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [opened, synchronize, reopened]
|
|
6
|
+
push:
|
|
7
|
+
branches: [main, staging]
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
inputs:
|
|
10
|
+
environment:
|
|
11
|
+
description: 'Target environment'
|
|
12
|
+
required: true
|
|
13
|
+
default: 'staging'
|
|
14
|
+
type: choice
|
|
15
|
+
options: [dev, staging, qa]
|
|
16
|
+
enable_load_tests:
|
|
17
|
+
description: 'Enable LOAD_SENSITIVE tests'
|
|
18
|
+
required: false
|
|
19
|
+
default: false
|
|
20
|
+
type: boolean
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
api-tests:
|
|
24
|
+
name: API Tests (${{ github.event.inputs.environment || 'staging' }})
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
timeout-minutes: 30
|
|
27
|
+
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
|
|
31
|
+
- uses: actions/setup-node@v4
|
|
32
|
+
with:
|
|
33
|
+
node-version: '22'
|
|
34
|
+
cache: 'npm'
|
|
35
|
+
|
|
36
|
+
- name: Install dependencies
|
|
37
|
+
run: npm ci
|
|
38
|
+
|
|
39
|
+
- name: Install Playwright
|
|
40
|
+
run: npx playwright install --with-deps chromium
|
|
41
|
+
# API tests don't use a browser but Playwright install is required
|
|
42
|
+
|
|
43
|
+
- name: Run API tests (SAFE)
|
|
44
|
+
env:
|
|
45
|
+
API_TEST_BASE_URL: ${{ secrets[format('API_TEST_BASE_URL_{0}', upper(github.event.inputs.environment || 'STAGING'))] }}
|
|
46
|
+
API_TEST_ENV: ${{ github.event.inputs.environment || 'staging' }}
|
|
47
|
+
API_TEST_BEARER_TOKEN: ${{ secrets[format('API_TEST_BEARER_TOKEN_{0}', upper(github.event.inputs.environment || 'STAGING'))] }}
|
|
48
|
+
API_TEST_API_KEY: ${{ secrets[format('API_TEST_API_KEY_{0}', upper(github.event.inputs.environment || 'STAGING'))] }}
|
|
49
|
+
API_TEST_ENABLE_LOAD: ${{ github.event.inputs.enable_load_tests || 'false' }}
|
|
50
|
+
run: |
|
|
51
|
+
cd packages/api-tests
|
|
52
|
+
npx playwright test --grep @safe --reporter=junit,list
|
|
53
|
+
|
|
54
|
+
- name: Run API tests (MUTATING - staging only)
|
|
55
|
+
if: github.event.inputs.environment == 'staging' || github.ref == 'refs/heads/staging'
|
|
56
|
+
env:
|
|
57
|
+
API_TEST_BASE_URL: ${{ secrets.API_TEST_BASE_URL_STAGING }}
|
|
58
|
+
API_TEST_ENV: staging
|
|
59
|
+
API_TEST_BEARER_TOKEN: ${{ secrets.API_TEST_BEARER_TOKEN_STAGING }}
|
|
60
|
+
run: |
|
|
61
|
+
cd packages/api-tests
|
|
62
|
+
npx playwright test --grep @mutating --reporter=junit,list
|
|
63
|
+
|
|
64
|
+
- name: Run Security tests
|
|
65
|
+
env:
|
|
66
|
+
API_TEST_BASE_URL: ${{ secrets[format('API_TEST_BASE_URL_{0}', upper(github.event.inputs.environment || 'STAGING'))] }}
|
|
67
|
+
API_TEST_ENV: ${{ github.event.inputs.environment || 'staging' }}
|
|
68
|
+
API_TEST_BEARER_TOKEN: ${{ secrets[format('API_TEST_BEARER_TOKEN_{0}', upper(github.event.inputs.environment || 'STAGING'))] }}
|
|
69
|
+
run: |
|
|
70
|
+
cd packages/api-tests
|
|
71
|
+
npx playwright test --grep @security --reporter=junit,list
|
|
72
|
+
|
|
73
|
+
- name: Upload JUnit results
|
|
74
|
+
uses: actions/upload-artifact@v4
|
|
75
|
+
if: always()
|
|
76
|
+
with:
|
|
77
|
+
name: api-test-results-junit
|
|
78
|
+
path: packages/api-tests/test-results/api-results.xml
|
|
79
|
+
|
|
80
|
+
- name: Upload JSON results
|
|
81
|
+
uses: actions/upload-artifact@v4
|
|
82
|
+
if: always()
|
|
83
|
+
with:
|
|
84
|
+
name: api-test-results-json
|
|
85
|
+
path: packages/api-tests/test-results/api-results.json
|
|
86
|
+
|
|
87
|
+
- name: Publish Test Report
|
|
88
|
+
uses: mikepenz/action-junit-report@v4
|
|
89
|
+
if: always()
|
|
90
|
+
with:
|
|
91
|
+
report_paths: 'packages/api-tests/test-results/api-results.xml'
|
|
92
|
+
check_name: 'API Test Results'
|
|
93
|
+
fail_on_failure: true
|
|
94
|
+
require_tests: false
|
|
95
|
+
|
|
96
|
+
- name: Comment PR with test summary
|
|
97
|
+
uses: actions/github-script@v7
|
|
98
|
+
if: github.event_name == 'pull_request' && always()
|
|
99
|
+
with:
|
|
100
|
+
script: |
|
|
101
|
+
const fs = require('fs');
|
|
102
|
+
const jsonPath = 'packages/api-tests/test-results/api-results.json';
|
|
103
|
+
if (!fs.existsSync(jsonPath)) return;
|
|
104
|
+
const results = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
|
105
|
+
const stats = results.stats || {};
|
|
106
|
+
const status = stats.unexpected > 0 ? '❌' : '✅';
|
|
107
|
+
const body = [
|
|
108
|
+
`## ${status} API Test Results`,
|
|
109
|
+
'',
|
|
110
|
+
`| Metric | Count |`,
|
|
111
|
+
`|--------|-------|`,
|
|
112
|
+
`| Total | ${stats.expected + stats.unexpected || 0} |`,
|
|
113
|
+
`| Passed | ${stats.expected || 0} |`,
|
|
114
|
+
`| Failed | ${stats.unexpected || 0} |`,
|
|
115
|
+
`| Skipped | ${stats.skipped || 0} |`,
|
|
116
|
+
'',
|
|
117
|
+
stats.unexpected > 0
|
|
118
|
+
? '> ⚠️ **CRITICAL API tests failed.** Merge is blocked until failures are resolved.'
|
|
119
|
+
: '> All API tests passed.',
|
|
120
|
+
].join('\n');
|
|
121
|
+
|
|
122
|
+
github.rest.issues.createComment({
|
|
123
|
+
issue_number: context.issue.number,
|
|
124
|
+
owner: context.repo.owner,
|
|
125
|
+
repo: context.repo.repo,
|
|
126
|
+
body,
|
|
127
|
+
});
|
package/ci/gitlab-ci.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
api-tests:
|
|
2
|
+
stage: test
|
|
3
|
+
image: mcr.microsoft.com/playwright:v1.47.0-jammy
|
|
4
|
+
variables:
|
|
5
|
+
API_TEST_ENV: ${CI_ENVIRONMENT_NAME:-staging}
|
|
6
|
+
script:
|
|
7
|
+
- cd packages/api-tests
|
|
8
|
+
- npm ci
|
|
9
|
+
- npx playwright test --grep "@safe|@security" --reporter=junit,list
|
|
10
|
+
artifacts:
|
|
11
|
+
when: always
|
|
12
|
+
reports:
|
|
13
|
+
junit: packages/api-tests/test-results/api-results.xml
|
|
14
|
+
paths:
|
|
15
|
+
- packages/api-tests/test-results/
|
|
16
|
+
only:
|
|
17
|
+
- merge_requests
|
|
18
|
+
- main
|
|
19
|
+
- staging
|
|
20
|
+
|
|
21
|
+
api-tests-mutating:
|
|
22
|
+
stage: test
|
|
23
|
+
extends: api-tests
|
|
24
|
+
script:
|
|
25
|
+
- cd packages/api-tests
|
|
26
|
+
- npm ci
|
|
27
|
+
- npx playwright test --grep @mutating --reporter=junit,list
|
|
28
|
+
only:
|
|
29
|
+
- staging
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface ApiTestRunResult {
|
|
2
|
+
runId: string;
|
|
3
|
+
suiteId: string;
|
|
4
|
+
projectId: string;
|
|
5
|
+
environment: string;
|
|
6
|
+
startedAt: string; // ISO 8601
|
|
7
|
+
completedAt: string; // ISO 8601
|
|
8
|
+
durationMs: number;
|
|
9
|
+
executionRiskBreakdown: {
|
|
10
|
+
safe: number;
|
|
11
|
+
mutating: number;
|
|
12
|
+
loadSensitive: number;
|
|
13
|
+
};
|
|
14
|
+
summary: {
|
|
15
|
+
total: number;
|
|
16
|
+
passed: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
skipped: number;
|
|
19
|
+
};
|
|
20
|
+
criticalFailures: number; // failures where priority === 'CRITICAL'
|
|
21
|
+
results: ApiTestCaseResult[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ApiTestCaseResult {
|
|
25
|
+
testCaseId: string;
|
|
26
|
+
title: string;
|
|
27
|
+
dimension: string;
|
|
28
|
+
kind: string;
|
|
29
|
+
priority: 'CRITICAL' | 'HIGH' | 'MEDIUM';
|
|
30
|
+
endpoint: string;
|
|
31
|
+
executionRisk: 'SAFE' | 'MUTATING' | 'LOAD_SENSITIVE';
|
|
32
|
+
status: 'passed' | 'failed' | 'skipped' | 'timedOut';
|
|
33
|
+
durationMs: number;
|
|
34
|
+
attempts: number;
|
|
35
|
+
retriedOn?: string[]; // e.g. ['ECONNRESET', '503']
|
|
36
|
+
actualStatus?: number; // HTTP status received
|
|
37
|
+
expectedStatus?: string; // documented expected status
|
|
38
|
+
failureReason?: string;
|
|
39
|
+
requestLog?: ApiRequestLog;
|
|
40
|
+
responseLog?: ApiResponseLog;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ApiRequestLog {
|
|
44
|
+
method: string;
|
|
45
|
+
url: string;
|
|
46
|
+
headers: Record<string, string>; // sensitive headers masked
|
|
47
|
+
body?: string; // request body (PII masked)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ApiResponseLog {
|
|
51
|
+
status: number;
|
|
52
|
+
headers: Record<string, string>;
|
|
53
|
+
body?: string; // first 1000 chars
|
|
54
|
+
durationMs: number;
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@detiq/api-tests",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.26",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "playwright test",
|
|
8
|
+
"test:safe": "playwright test --grep @safe",
|
|
9
|
+
"test:mutating": "playwright test --grep @mutating",
|
|
10
|
+
"test:security": "playwright test --grep @security",
|
|
11
|
+
"test:ci": "playwright test --reporter=junit"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@playwright/test": "workspace:*",
|
|
15
|
+
"typescript": "workspace:*"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {}
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineConfig } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
testDir: './src/tests',
|
|
5
|
+
testMatch: /.*\.api\.spec\.ts$/,
|
|
6
|
+
timeout: 30_000,
|
|
7
|
+
retries: 0, // Retry logic is handled per-request in withRetry() — not at test level
|
|
8
|
+
workers: 4,
|
|
9
|
+
reporter: [
|
|
10
|
+
['list'],
|
|
11
|
+
['junit', { outputFile: 'test-results/api-results.xml' }],
|
|
12
|
+
['json', { outputFile: 'test-results/api-results.json' }],
|
|
13
|
+
],
|
|
14
|
+
use: {
|
|
15
|
+
extraHTTPHeaders: {
|
|
16
|
+
'Accept': 'application/json',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
projects: [
|
|
20
|
+
{
|
|
21
|
+
name: 'api',
|
|
22
|
+
testMatch: /.*\.api\.spec\.ts$/,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { APIRequestContext, APIResponse } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/** Minimal auth profile type for building request headers. */
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
type AuthProfile = { type: string; [key: string]: any };
|
|
6
|
+
|
|
7
|
+
export interface ApiClientOptions {
|
|
8
|
+
/** Pre-created Playwright API request context. */
|
|
9
|
+
request: APIRequestContext;
|
|
10
|
+
/** Optional bearer token — sent as Authorization: Bearer <token>. */
|
|
11
|
+
bearerToken?: string;
|
|
12
|
+
/** Additional headers to include on every request. */
|
|
13
|
+
extraHeaders?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class BaseApiClient {
|
|
17
|
+
private readonly request: APIRequestContext;
|
|
18
|
+
private readonly defaultHeaders: Record<string, string>;
|
|
19
|
+
|
|
20
|
+
private constructor(request: APIRequestContext, defaultHeaders: Record<string, string>) {
|
|
21
|
+
this.request = request;
|
|
22
|
+
this.defaultHeaders = defaultHeaders;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static create(options: ApiClientOptions): BaseApiClient {
|
|
26
|
+
const headers: Record<string, string> = { ...options.extraHeaders };
|
|
27
|
+
if (options.bearerToken) {
|
|
28
|
+
headers['Authorization'] = `Bearer ${options.bearerToken}`;
|
|
29
|
+
}
|
|
30
|
+
return new BaseApiClient(options.request, headers);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private mergeHeaders(headers?: Record<string, string>): Record<string, string> {
|
|
34
|
+
return { ...this.defaultHeaders, ...headers };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async get(path: string, headers?: Record<string, string>): Promise<APIResponse> {
|
|
38
|
+
return this.request.get(path, { headers: this.mergeHeaders(headers) });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async post(path: string, body: unknown, headers?: Record<string, string>): Promise<APIResponse> {
|
|
42
|
+
return this.request.post(path, { data: body, headers: this.mergeHeaders(headers) });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async put(path: string, body: unknown, headers?: Record<string, string>): Promise<APIResponse> {
|
|
46
|
+
return this.request.put(path, { data: body, headers: this.mergeHeaders(headers) });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async patch(path: string, body: unknown, headers?: Record<string, string>): Promise<APIResponse> {
|
|
50
|
+
return this.request.patch(path, { data: body, headers: this.mergeHeaders(headers) });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async delete(path: string, headers?: Record<string, string>): Promise<APIResponse> {
|
|
54
|
+
return this.request.delete(path, { headers: this.mergeHeaders(headers) });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async dispose(): Promise<void> {
|
|
58
|
+
await this.request.dispose();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve an AuthProfile to the headers needed for API requests. */
|
|
63
|
+
export function authHeaders(profile: AuthProfile): Record<string, string> {
|
|
64
|
+
switch (profile.type) {
|
|
65
|
+
case 'none':
|
|
66
|
+
return {};
|
|
67
|
+
case 'bearer_static':
|
|
68
|
+
// token_enc should be decrypted server-side before reaching tests;
|
|
69
|
+
// in test context, use the PLAINTEXT token from environment variable.
|
|
70
|
+
return { Authorization: `Bearer ${process.env['API_TEST_BEARER_TOKEN'] ?? ''}` };
|
|
71
|
+
case 'api_key':
|
|
72
|
+
return { [profile.headerName]: process.env['API_TEST_API_KEY'] ?? '' };
|
|
73
|
+
case 'basic': {
|
|
74
|
+
const creds = Buffer.from(
|
|
75
|
+
`${profile.username}:${process.env['API_TEST_PASSWORD'] ?? ''}`,
|
|
76
|
+
).toString('base64');
|
|
77
|
+
return { Authorization: `Basic ${creds}` };
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
import type { APIResponse } from '@playwright/test';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Assert HTTP status code with a descriptive failure message.
|
|
6
|
+
* Prefers this over raw expect(res.status()).toBe(N) so failures show the response body.
|
|
7
|
+
*/
|
|
8
|
+
export async function assertStatus(
|
|
9
|
+
response: APIResponse,
|
|
10
|
+
expectedStatus: number,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
if (response.status() !== expectedStatus) {
|
|
13
|
+
let body = '<no body>';
|
|
14
|
+
try {
|
|
15
|
+
body = await response.text();
|
|
16
|
+
} catch { /* ignore */ }
|
|
17
|
+
expect(
|
|
18
|
+
response.status(),
|
|
19
|
+
`Expected HTTP ${expectedStatus} but got ${response.status()}.\nBody: ${body.slice(0, 500)}`,
|
|
20
|
+
).toBe(expectedStatus);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Assert that the response body matches a JSON schema shape.
|
|
26
|
+
* Checks required top-level keys and their types.
|
|
27
|
+
*/
|
|
28
|
+
export async function assertJsonShape(
|
|
29
|
+
response: APIResponse,
|
|
30
|
+
shape: Record<string, 'string' | 'number' | 'boolean' | 'object' | 'array'>,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const json = await response.json();
|
|
33
|
+
for (const [key, type] of Object.entries(shape)) {
|
|
34
|
+
expect(json, `Response missing key "${key}"`).toHaveProperty(key);
|
|
35
|
+
if (type === 'array') {
|
|
36
|
+
expect(Array.isArray(json[key]), `Expected "${key}" to be an array`).toBe(true);
|
|
37
|
+
} else {
|
|
38
|
+
expect(typeof json[key], `Expected "${key}" to be "${type}"`).toBe(type);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Assert that security headers are present on an API response.
|
|
45
|
+
* API8: Security Misconfiguration check.
|
|
46
|
+
*/
|
|
47
|
+
export function assertSecurityHeaders(response: APIResponse): void {
|
|
48
|
+
const headers = response.headers();
|
|
49
|
+
expect(headers['x-content-type-options'], 'Missing X-Content-Type-Options').toBe('nosniff');
|
|
50
|
+
const frameOptions = headers['x-frame-options'];
|
|
51
|
+
expect(
|
|
52
|
+
frameOptions === 'DENY' || frameOptions === 'SAMEORIGIN',
|
|
53
|
+
`X-Frame-Options should be DENY or SAMEORIGIN, got "${frameOptions}"`,
|
|
54
|
+
).toBe(true);
|
|
55
|
+
expect(headers['strict-transport-security'], 'Missing Strict-Transport-Security').toBeTruthy();
|
|
56
|
+
expect(headers['content-security-policy'], 'Missing Content-Security-Policy').toBeTruthy();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Assert that no sensitive fields are exposed in a JSON response.
|
|
61
|
+
* API8 / Sensitive Data Exposure check.
|
|
62
|
+
*/
|
|
63
|
+
export async function assertNoSensitiveFields(response: APIResponse): Promise<void> {
|
|
64
|
+
const sensitiveFields = ['password', 'secret', 'token', 'access_token', 'private_key', 'cvv', 'ssn'];
|
|
65
|
+
let json: unknown;
|
|
66
|
+
try {
|
|
67
|
+
json = await response.json();
|
|
68
|
+
} catch {
|
|
69
|
+
return; // non-JSON response — skip
|
|
70
|
+
}
|
|
71
|
+
const text = JSON.stringify(json).toLowerCase();
|
|
72
|
+
for (const field of sensitiveFields) {
|
|
73
|
+
const pattern = new RegExp(`"${field}"\\s*:\\s*"[^"]{4,}"`, 'i');
|
|
74
|
+
expect(
|
|
75
|
+
pattern.test(text),
|
|
76
|
+
`Response body appears to contain sensitive field "${field}"`,
|
|
77
|
+
).toBe(false);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks async cleanup callbacks so they can be run in LIFO order after a test
|
|
3
|
+
* completes — even if the test fails.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* const cleanup = new CleanupRegistry();
|
|
7
|
+
* cleanup.register(async () => client.delete(`/users/${id}`));
|
|
8
|
+
* // In afterAll/afterEach: await cleanup.run();
|
|
9
|
+
*/
|
|
10
|
+
export class CleanupRegistry {
|
|
11
|
+
private callbacks: Array<() => Promise<void>> = [];
|
|
12
|
+
|
|
13
|
+
/** Register an async cleanup callback. Callbacks run in reverse order. */
|
|
14
|
+
register(callback: () => Promise<void>): void {
|
|
15
|
+
this.callbacks.push(callback);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Run all registered callbacks in LIFO order. Logs failures but does not throw. */
|
|
19
|
+
async run(): Promise<void> {
|
|
20
|
+
const toClean = [...this.callbacks].reverse();
|
|
21
|
+
this.callbacks = [];
|
|
22
|
+
|
|
23
|
+
for (const cb of toClean) {
|
|
24
|
+
try {
|
|
25
|
+
await cb();
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.warn(`[CleanupRegistry] Cleanup callback failed: ${(err as Error).message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Number of pending cleanup callbacks. */
|
|
33
|
+
get size(): number {
|
|
34
|
+
return this.callbacks.length;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment guard for MUTATING and LOAD_SENSITIVE tests.
|
|
3
|
+
*
|
|
4
|
+
* MUTATING tests must never run against production without explicit bypass.
|
|
5
|
+
* Call assertNotProduction() at the start of any MUTATING test's beforeAll/beforeEach.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const PRODUCTION_IDENTIFIERS = ['prod', 'production', 'live'];
|
|
9
|
+
|
|
10
|
+
export function assertNotProduction(): void {
|
|
11
|
+
const env = (process.env.API_TEST_ENV ?? '').toLowerCase();
|
|
12
|
+
const isProduction = PRODUCTION_IDENTIFIERS.some((id) => env.includes(id));
|
|
13
|
+
if (isProduction) {
|
|
14
|
+
const bypass = process.env.API_TEST_ALLOW_MUTATING_ON_PROD === 'true';
|
|
15
|
+
if (!bypass) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`ENVIRONMENT_GUARD: MUTATING tests cannot run against production environment "${env}". ` +
|
|
18
|
+
`Set API_TEST_ALLOW_MUTATING_ON_PROD=true to bypass (requires explicit approval).`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
console.warn(
|
|
22
|
+
`[EnvironmentGuard] WARNING: MUTATING test running against production environment "${env}". ` +
|
|
23
|
+
`This was explicitly bypassed with API_TEST_ALLOW_MUTATING_ON_PROD=true.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertLoadTestsEnabled(): void {
|
|
29
|
+
if (process.env.API_TEST_ENABLE_LOAD !== 'true') {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`LOAD_SENSITIVE tests are disabled by default. ` +
|
|
32
|
+
`Set API_TEST_ENABLE_LOAD=true to enable load and rate-limit tests.`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getBaseUrl(): string {
|
|
38
|
+
const url = process.env.API_TEST_BASE_URL;
|
|
39
|
+
if (!url) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'API_TEST_BASE_URL environment variable is not set. ' +
|
|
42
|
+
'Set it to the base URL of the API under test (e.g. https://staging.api.example.com).',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return url;
|
|
46
|
+
}
|
package/src/lib/retry.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { APIResponse } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
export interface RetryOptions {
|
|
4
|
+
maxAttempts: number;
|
|
5
|
+
retryableStatuses: number[];
|
|
6
|
+
backoffMs: number[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
|
10
|
+
maxAttempts: 3,
|
|
11
|
+
retryableStatuses: [503, 502, 504],
|
|
12
|
+
backoffMs: [1000, 3000],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wraps an API call with retry logic.
|
|
17
|
+
* Retries only on transient network errors (503, 502, 504) or connection timeouts.
|
|
18
|
+
* Never retries on 4xx — those are genuine test failures.
|
|
19
|
+
*/
|
|
20
|
+
export async function withRetry(
|
|
21
|
+
fn: () => Promise<APIResponse>,
|
|
22
|
+
options: Partial<RetryOptions> = {},
|
|
23
|
+
): Promise<APIResponse> {
|
|
24
|
+
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
|
25
|
+
let lastError: Error | undefined;
|
|
26
|
+
|
|
27
|
+
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
|
|
28
|
+
try {
|
|
29
|
+
const response = await fn();
|
|
30
|
+
if (opts.retryableStatuses.includes(response.status()) && attempt < opts.maxAttempts - 1) {
|
|
31
|
+
const delay = opts.backoffMs[attempt] ?? opts.backoffMs[opts.backoffMs.length - 1];
|
|
32
|
+
await sleep(delay);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
return response;
|
|
36
|
+
} catch (err: any) {
|
|
37
|
+
lastError = err;
|
|
38
|
+
const isTransient =
|
|
39
|
+
err?.message?.includes('ECONNRESET') ||
|
|
40
|
+
err?.message?.includes('ECONNREFUSED') ||
|
|
41
|
+
err?.message?.includes('timeout');
|
|
42
|
+
if (!isTransient || attempt >= opts.maxAttempts - 1) throw err;
|
|
43
|
+
const delay = opts.backoffMs[attempt] ?? opts.backoffMs[opts.backoffMs.length - 1];
|
|
44
|
+
await sleep(delay);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
throw lastError ?? new Error('Retry exhausted with no response');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sleep(ms: number): Promise<void> {
|
|
52
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { BaseApiClient } from '../lib/api-client.js';
|
|
3
|
+
import { assertStatus, assertJsonShape, assertNoSensitiveFields } from '../lib/assertions.js';
|
|
4
|
+
import { CleanupRegistry } from '../lib/cleanup-registry.js';
|
|
5
|
+
import { withRetry } from '../lib/retry.js';
|
|
6
|
+
import { getBaseUrl, assertNotProduction } from '../lib/environment-guard.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Authentication and Authorization API tests.
|
|
10
|
+
*
|
|
11
|
+
* @tag @safe tests: read-only, safe to run anywhere
|
|
12
|
+
* @tag @mutating tests: create/delete resources — assertNotProduction() called first
|
|
13
|
+
* @tag @security tests: deliberate invalid/malformed auth attempts
|
|
14
|
+
*/
|
|
15
|
+
test.describe('@safe Authentication — happy paths', () => {
|
|
16
|
+
let client: BaseApiClient;
|
|
17
|
+
|
|
18
|
+
test.beforeAll(async ({ playwright }) => {
|
|
19
|
+
client = BaseApiClient.create({
|
|
20
|
+
request: await playwright.request.newContext({ baseURL: getBaseUrl() }),
|
|
21
|
+
bearerToken: process.env.API_TEST_BEARER_TOKEN,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test.afterAll(async () => {
|
|
26
|
+
await client.dispose();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('authenticated request returns 200 not 401', async () => {
|
|
30
|
+
const result = await client.get('/projects');
|
|
31
|
+
assertStatus(result, 200);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('response body contains no plaintext secrets', async () => {
|
|
35
|
+
const result = await client.get('/projects');
|
|
36
|
+
assertStatus(result, 200);
|
|
37
|
+
assertNoSensitiveFields(result);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test.describe('@security Authentication — negative paths', () => {
|
|
42
|
+
let anonClient: BaseApiClient;
|
|
43
|
+
|
|
44
|
+
test.beforeAll(async ({ playwright }) => {
|
|
45
|
+
anonClient = BaseApiClient.create({
|
|
46
|
+
request: await playwright.request.newContext({ baseURL: getBaseUrl() }),
|
|
47
|
+
// no token — anonymous
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test.afterAll(async () => {
|
|
52
|
+
await anonClient.dispose();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('missing auth returns 401', async () => {
|
|
56
|
+
const result = await anonClient.get('/projects');
|
|
57
|
+
assertStatus(result, 401);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('invalid bearer token returns 401', async ({ playwright }) => {
|
|
61
|
+
const badClient = BaseApiClient.create({
|
|
62
|
+
request: await playwright.request.newContext({ baseURL: getBaseUrl() }),
|
|
63
|
+
bearerToken: 'invalid-token-abc123',
|
|
64
|
+
});
|
|
65
|
+
const result = await badClient.get('/projects');
|
|
66
|
+
assertStatus(result, 401);
|
|
67
|
+
await badClient.dispose();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test.describe('@mutating Auth Profile CRUD', () => {
|
|
72
|
+
let client: BaseApiClient;
|
|
73
|
+
const cleanup = new CleanupRegistry();
|
|
74
|
+
|
|
75
|
+
test.beforeAll(async ({ playwright }) => {
|
|
76
|
+
assertNotProduction();
|
|
77
|
+
client = BaseApiClient.create({
|
|
78
|
+
request: await playwright.request.newContext({ baseURL: getBaseUrl() }),
|
|
79
|
+
bearerToken: process.env.API_TEST_BEARER_TOKEN,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test.afterAll(async () => {
|
|
84
|
+
await cleanup.run();
|
|
85
|
+
await client.dispose();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('create and delete a bearer_static auth profile', async () => {
|
|
89
|
+
const projectId = process.env.API_TEST_PROJECT_ID ?? 'test-project-id';
|
|
90
|
+
|
|
91
|
+
const created = await withRetry(() =>
|
|
92
|
+
client.post(`/projects/${projectId}/api-spec/auth-profiles`, {
|
|
93
|
+
type: 'bearer_static',
|
|
94
|
+
name: 'Test Bearer Profile',
|
|
95
|
+
token: 'test-token-abc123',
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
assertStatus(created, 200);
|
|
99
|
+
const body = await created.json();
|
|
100
|
+
expect(body.id).toBeTruthy();
|
|
101
|
+
|
|
102
|
+
cleanup.register(async () => {
|
|
103
|
+
await client.delete(`/projects/${projectId}/api-spec/auth-profiles/${body.id}`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Verify masked in GET response
|
|
107
|
+
const fetched = await client.get(`/projects/${projectId}/api-spec/auth-profiles/${body.id}`);
|
|
108
|
+
assertStatus(fetched, 200);
|
|
109
|
+
const fetchedBody = await fetched.json();
|
|
110
|
+
expect(fetchedBody.token_enc).toBe('***');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { BaseApiClient } from '../lib/api-client.js';
|
|
3
|
+
import { assertStatus, assertSecurityHeaders } from '../lib/assertions.js';
|
|
4
|
+
import { getBaseUrl, assertNotProduction } from '../lib/environment-guard.js';
|
|
5
|
+
|
|
6
|
+
test.describe('@safe Smoke', () => {
|
|
7
|
+
let client: BaseApiClient;
|
|
8
|
+
|
|
9
|
+
test.beforeAll(async ({ playwright }) => {
|
|
10
|
+
client = BaseApiClient.create({
|
|
11
|
+
request: await playwright.request.newContext({ baseURL: getBaseUrl() }),
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test.afterAll(async () => {
|
|
16
|
+
await client.dispose();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('GET /health returns 200', async () => {
|
|
20
|
+
const result = await client.get('/health');
|
|
21
|
+
assertStatus(result, 200);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('API returns security headers', async () => {
|
|
25
|
+
const result = await client.get('/health');
|
|
26
|
+
assertSecurityHeaders(result);
|
|
27
|
+
});
|
|
28
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"rootDir": "src",
|
|
6
|
+
"module": "NodeNext",
|
|
7
|
+
"moduleResolution": "NodeNext",
|
|
8
|
+
"target": "ES2022",
|
|
9
|
+
"lib": ["ES2022"],
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"types": ["node"],
|
|
14
|
+
"paths": {
|
|
15
|
+
"@playwright/test": ["../../node_modules/playwright/test.d.ts"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist", "test-results"]
|
|
20
|
+
}
|
package/README.md
DELETED