@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 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
+ });
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.