@brunosps00/dev-workflow 0.8.0 → 0.8.1

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.
@@ -0,0 +1,157 @@
1
+ # Recipe: `pytest + httpx` (Python)
2
+
3
+ Use when the project already runs `pytest` (FastAPI, Starlette, Flask + pytest-flask). The async client matches FastAPI's design and gives you fixtures + parametrize for free.
4
+
5
+ ## File shape
6
+
7
+ `{{PRD_PATH}}/QA/scripts/api/test_RF_XX_[slug].py`
8
+
9
+ ```python
10
+ """RF-XX [slug] — API QA suite."""
11
+ import os
12
+ import pytest
13
+ import httpx
14
+
15
+ BASE = os.environ["API_BASE_URL"]
16
+ TOKEN_ADMIN = os.environ["QA_TOKEN_ADMIN"]
17
+ TOKEN_USER = os.environ["QA_TOKEN_USER"]
18
+ TOKEN_OTHER_ORG = os.environ.get("QA_TOKEN_OTHER_ORG", "")
19
+
20
+
21
+ @pytest.fixture
22
+ async def client():
23
+ async with httpx.AsyncClient(base_url=BASE, timeout=10.0) as c:
24
+ yield c
25
+
26
+
27
+ def auth(token: str) -> dict:
28
+ return {"Authorization": f"Bearer {token}"}
29
+
30
+
31
+ # ---------- happy path ----------
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_create_user_happy_path(client):
35
+ r = await client.post("/users", headers=auth(TOKEN_ADMIN),
36
+ json={"email": "qa@example.com", "name": "QA"})
37
+ assert r.status_code == 201, r.text
38
+ body = r.json()
39
+ assert body["id"]
40
+ assert body["email"] == "qa@example.com"
41
+ pytest.created_user_id = body["id"] # share via module attr or use a fixture
42
+
43
+
44
+ # ---------- 4xx validation ----------
45
+
46
+ @pytest.mark.asyncio
47
+ @pytest.mark.parametrize("payload, missing_field", [
48
+ ({"name": "No email"}, "email"),
49
+ ({"email": "no-name@x.com"}, "name"),
50
+ ({"email": "not-an-email", "name": "X"}, "email"),
51
+ ])
52
+ async def test_create_user_validation(client, payload, missing_field):
53
+ r = await client.post("/users", headers=auth(TOKEN_ADMIN), json=payload)
54
+ assert r.status_code == 422, r.text
55
+ assert missing_field in r.json()["error"]["message"].lower()
56
+
57
+
58
+ # ---------- 4xx auth ----------
59
+
60
+ @pytest.mark.asyncio
61
+ async def test_create_user_no_token(client):
62
+ r = await client.post("/users", json={"email": "x@y.com", "name": "x"})
63
+ assert r.status_code == 401
64
+
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_create_user_expired_token(client):
68
+ r = await client.post("/users",
69
+ headers={"Authorization": "Bearer expired.token.here"},
70
+ json={"email": "x@y.com", "name": "x"})
71
+ assert r.status_code == 401
72
+
73
+
74
+ # ---------- 4xx authz cross-tenant ----------
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_get_user_other_org_denied(client):
78
+ if not TOKEN_OTHER_ORG:
79
+ pytest.skip("QA_TOKEN_OTHER_ORG not set")
80
+ r = await client.get(f"/users/{pytest.created_user_id}",
81
+ headers=auth(TOKEN_OTHER_ORG))
82
+ assert r.status_code in (403, 404)
83
+
84
+
85
+ # ---------- contract drift ----------
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_get_user_contract(client):
89
+ r = await client.get(f"/users/{pytest.created_user_id}",
90
+ headers=auth(TOKEN_ADMIN))
91
+ assert r.status_code == 200
92
+ body = r.json()
93
+ for field in ("id", "email", "name", "created_at"):
94
+ assert field in body, f"missing {field}"
95
+ for leaked in ("password_hash", "internal_id", "_raw"):
96
+ assert leaked not in body, f"leaked {leaked}"
97
+ ```
98
+
99
+ ## Configuration
100
+
101
+ `pyproject.toml` (or `pytest.ini`):
102
+
103
+ ```toml
104
+ [tool.pytest.ini_options]
105
+ asyncio_mode = "auto"
106
+ testpaths = ["QA/scripts/api"]
107
+ ```
108
+
109
+ ## Running
110
+
111
+ ```bash
112
+ # all RF tests
113
+ pytest QA/scripts/api/
114
+
115
+ # one RF
116
+ pytest QA/scripts/api/test_RF_01_create_user.py -v
117
+
118
+ # capture log to QA/logs/api/
119
+ pytest QA/scripts/api/ -v --tb=short 2>&1 | tee QA/logs/api/run-$(date +%F).log
120
+ ```
121
+
122
+ ## Logging request/response (for QA evidence)
123
+
124
+ Wrap `client` to log every call:
125
+
126
+ ```python
127
+ import json, time
128
+ from pathlib import Path
129
+
130
+ LOG = Path("QA/logs/api/RF-XX-create-user.log")
131
+
132
+ class LoggingClient(httpx.AsyncClient):
133
+ async def request(self, method, url, **kw):
134
+ start = time.time()
135
+ r = await super().request(method, url, **kw)
136
+ ms = int((time.time() - start) * 1000)
137
+ LOG.parent.mkdir(parents=True, exist_ok=True)
138
+ with LOG.open("a") as f:
139
+ f.write(json.dumps({
140
+ "ts": time.time(),
141
+ "method": method,
142
+ "url": str(r.request.url),
143
+ "status": r.status_code,
144
+ "ms": ms,
145
+ "request_body": kw.get("json"),
146
+ "response_body": r.json() if r.headers.get("content-type", "").startswith("application/json") else None,
147
+ }) + "\n")
148
+ return r
149
+ ```
150
+
151
+ ## Pros / cons
152
+
153
+ - **Pro**: parametrize covers the 4xx matrix in one block.
154
+ - **Pro**: fixtures handle auth + setup/teardown cleanly.
155
+ - **Pro**: integrates with existing `pytest` config + CI.
156
+ - **Con**: not portable to non-Python projects.
157
+ - **Con**: requires `httpx` and `pytest-asyncio` in dev deps.
@@ -0,0 +1,173 @@
1
+ # Recipe: `reqwest + tokio::test` (Rust)
2
+
3
+ Use for Axum, Actix-web, Rocket. Async HTTP client that pairs naturally with each framework's tower / actix-rt runtime.
4
+
5
+ Two execution modes:
6
+
7
+ - **A: against a running server** — best when the project already exposes the API in dev.
8
+ - **B: in-process via `axum::Router::oneshot`** — fastest, no port, no flake.
9
+
10
+ ## File shape (mode A — running server, framework-agnostic)
11
+
12
+ `{{PRD_PATH}}/QA/scripts/api/rf_xx_[slug].rs` (typically a `tests/` integration target):
13
+
14
+ ```rust
15
+ // RF-XX [slug] — API QA suite
16
+ use reqwest::{Client, StatusCode};
17
+ use serde_json::{json, Value};
18
+
19
+ fn base() -> String { std::env::var("API_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()) }
20
+ fn token_admin() -> String { std::env::var("QA_TOKEN_ADMIN").unwrap_or_default() }
21
+ fn token_other() -> String { std::env::var("QA_TOKEN_OTHER_ORG").unwrap_or_default() }
22
+
23
+ fn client() -> Client {
24
+ Client::builder().timeout(std::time::Duration::from_secs(10)).build().unwrap()
25
+ }
26
+
27
+ #[tokio::test]
28
+ async fn happy_path_returns_201() {
29
+ let r = client().post(format!("{}/users", base()))
30
+ .bearer_auth(token_admin())
31
+ .json(&json!({ "email": format!("qa-{}@example.com", uuid::Uuid::new_v4()), "name": "QA" }))
32
+ .send().await.unwrap();
33
+ assert_eq!(r.status(), StatusCode::CREATED);
34
+ let body: Value = r.json().await.unwrap();
35
+ assert!(body.get("id").is_some());
36
+ }
37
+
38
+ #[tokio::test]
39
+ async fn validation_missing_email_returns_422() {
40
+ let r = client().post(format!("{}/users", base()))
41
+ .bearer_auth(token_admin())
42
+ .json(&json!({ "name": "No email" }))
43
+ .send().await.unwrap();
44
+ assert_eq!(r.status(), StatusCode::UNPROCESSABLE_ENTITY);
45
+ let body: Value = r.json().await.unwrap();
46
+ let msg = body["error"]["message"].as_str().unwrap_or_default().to_lowercase();
47
+ assert!(msg.contains("email"));
48
+ }
49
+
50
+ #[tokio::test]
51
+ async fn no_token_returns_401() {
52
+ let r = client().post(format!("{}/users", base()))
53
+ .json(&json!({ "email": "x@y.com", "name": "x" }))
54
+ .send().await.unwrap();
55
+ assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
56
+ }
57
+
58
+ #[tokio::test]
59
+ async fn cross_tenant_denied() {
60
+ if token_other().is_empty() { return; }
61
+ let r = client().get(format!("{}/users/{}", base(), "00000000-0000-0000-0000-000000000001"))
62
+ .bearer_auth(token_other())
63
+ .send().await.unwrap();
64
+ assert!(matches!(r.status(), StatusCode::FORBIDDEN | StatusCode::NOT_FOUND));
65
+ }
66
+
67
+ #[tokio::test]
68
+ async fn contract_required_fields_present_no_leaks() {
69
+ let create = client().post(format!("{}/users", base()))
70
+ .bearer_auth(token_admin())
71
+ .json(&json!({ "email": format!("c-{}@example.com", uuid::Uuid::new_v4()), "name": "C" }))
72
+ .send().await.unwrap();
73
+ let created: Value = create.json().await.unwrap();
74
+ let id = created["id"].as_str().unwrap();
75
+
76
+ let get = client().get(format!("{}/users/{}", base(), id))
77
+ .bearer_auth(token_admin())
78
+ .send().await.unwrap();
79
+ assert_eq!(get.status(), StatusCode::OK);
80
+ let body: Value = get.json().await.unwrap();
81
+
82
+ for f in ["id", "email", "name", "created_at"] { assert!(body.get(f).is_some(), "missing {f}"); }
83
+ for leak in ["password_hash", "internal_id", "_raw"] {
84
+ assert!(body.get(leak).is_none(), "leaked {leak}");
85
+ }
86
+ }
87
+ ```
88
+
89
+ ## File shape (mode B — Axum in-process via `Router::oneshot`)
90
+
91
+ ```rust
92
+ use axum::body::Body;
93
+ use axum::http::{Request, StatusCode};
94
+ use http_body_util::BodyExt;
95
+ use my_app::build_router;
96
+ use serde_json::{json, Value};
97
+ use tower::util::ServiceExt;
98
+
99
+ #[tokio::test]
100
+ async fn happy_path_oneshot() {
101
+ let app = build_router().await;
102
+ let req = Request::post("/users")
103
+ .header("authorization", "Bearer test-admin")
104
+ .header("content-type", "application/json")
105
+ .body(Body::from(json!({"email":"qa@example.com","name":"QA"}).to_string()))
106
+ .unwrap();
107
+ let res = app.oneshot(req).await.unwrap();
108
+ assert_eq!(res.status(), StatusCode::CREATED);
109
+ let bytes = res.into_body().collect().await.unwrap().to_bytes();
110
+ let body: Value = serde_json::from_slice(&bytes).unwrap();
111
+ assert!(body.get("id").is_some());
112
+ }
113
+ ```
114
+
115
+ `build_router()` is the project's exported async function that returns the `axum::Router` — same one used in `main.rs`.
116
+
117
+ ## Configuration
118
+
119
+ `Cargo.toml`:
120
+
121
+ ```toml
122
+ [dev-dependencies]
123
+ reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
124
+ tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
125
+ serde_json = "1"
126
+ uuid = { version = "1", features = ["v4"] }
127
+ # mode B (Axum):
128
+ http-body-util = "0.1"
129
+ tower = { version = "0.5", features = ["util"] }
130
+ ```
131
+
132
+ ## Running
133
+
134
+ ```bash
135
+ # all
136
+ cargo test --test rf_xx_create_user -- --nocapture
137
+
138
+ # log to QA/logs/api/
139
+ cargo test --test rf_xx_create_user -- --nocapture 2>&1 \
140
+ | tee "QA/logs/api/run-$(date +%F).log"
141
+ ```
142
+
143
+ ## Logging request/response
144
+
145
+ `reqwest` doesn't ship a logging middleware out of the box. Two options:
146
+
147
+ - **Wrap at the test layer**: small helper that calls `client.execute(req)` and writes JSONL.
148
+ - **Tower middleware in mode B**: insert a `tower::Layer` that logs request/response. Reuse the project's tracing/logging layer if it has one.
149
+
150
+ ```rust
151
+ use std::fs::OpenOptions;
152
+ use std::io::Write;
153
+
154
+ async fn log_request(method: &str, url: &str, status: u16, ms: u128, body: &str) {
155
+ std::fs::create_dir_all("QA/logs/api").ok();
156
+ let mut f = OpenOptions::new().create(true).append(true)
157
+ .open("QA/logs/api/RF-XX-create-user.log").unwrap();
158
+ let entry = serde_json::json!({
159
+ "ts": chrono::Utc::now().timestamp_millis(),
160
+ "method": method, "url": url, "status": status, "ms": ms,
161
+ "response_body": body,
162
+ });
163
+ writeln!(f, "{entry}").ok();
164
+ }
165
+ ```
166
+
167
+ ## Pros / cons
168
+
169
+ - **Pro (mode B)**: in-process, no port, fastest, deterministic.
170
+ - **Pro**: `tokio::test` integrates with the project's existing `cargo test` flow.
171
+ - **Pro**: type-safe assertions on response bodies.
172
+ - **Con (mode A)**: depends on the server being up before `cargo test`.
173
+ - **Con (mode B)**: requires the project to expose a `build_router()` factory.
@@ -0,0 +1,153 @@
1
+ # Recipe: `supertest` (Node.js / TypeScript)
2
+
3
+ Use for Fastify, Express, NestJS projects that already run `vitest` or `jest`. supertest binds directly to the app instance and runs in-process — no port allocation, no flake.
4
+
5
+ ## File shape
6
+
7
+ `{{PRD_PATH}}/QA/scripts/api/RF-XX-[slug].test.ts`
8
+
9
+ ```ts
10
+ // RF-XX [slug] — API QA suite
11
+ import request from 'supertest';
12
+ import { describe, expect, it, beforeAll } from 'vitest';
13
+ import { buildApp } from '../../../src/app'; // or: import app from '../../../src/server';
14
+
15
+ const BASE = process.env.API_BASE_URL ?? 'http://localhost:3000';
16
+ const TOKEN_ADMIN = process.env.QA_TOKEN_ADMIN ?? '';
17
+ const TOKEN_USER = process.env.QA_TOKEN_USER ?? '';
18
+ const TOKEN_OTHER_ORG = process.env.QA_TOKEN_OTHER_ORG ?? '';
19
+
20
+ let app: Awaited<ReturnType<typeof buildApp>>;
21
+ let createdUserId: string;
22
+
23
+ beforeAll(async () => { app = await buildApp(); });
24
+
25
+ const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
26
+
27
+ describe('RF-XX create user', () => {
28
+
29
+ it('happy path returns 201 and id', async () => {
30
+ const r = await request(app.server).post('/users')
31
+ .set(auth(TOKEN_ADMIN))
32
+ .send({ email: `qa-${Date.now()}@example.com`, name: 'QA' });
33
+ expect(r.status).toBe(201);
34
+ expect(r.body.id).toBeDefined();
35
+ createdUserId = r.body.id;
36
+ });
37
+
38
+ it.each([
39
+ [{ name: 'No email' }, 'email'],
40
+ [{ email: 'no-name@x.com' }, 'name'],
41
+ [{ email: 'not-an-email', name: 'X' }, 'email'],
42
+ ])('validation: %j → mentions %s', async (payload, missing) => {
43
+ const r = await request(app.server).post('/users')
44
+ .set(auth(TOKEN_ADMIN))
45
+ .send(payload);
46
+ expect(r.status).toBe(422);
47
+ expect(r.body.error.message.toLowerCase()).toContain(missing);
48
+ });
49
+
50
+ it('no token returns 401', async () => {
51
+ const r = await request(app.server).post('/users')
52
+ .send({ email: 'x@y.com', name: 'x' });
53
+ expect(r.status).toBe(401);
54
+ });
55
+
56
+ it('expired token returns 401', async () => {
57
+ const r = await request(app.server).post('/users')
58
+ .set({ Authorization: 'Bearer expired.token.here' })
59
+ .send({ email: 'x@y.com', name: 'x' });
60
+ expect(r.status).toBe(401);
61
+ });
62
+
63
+ it('cross-tenant access denied', async () => {
64
+ if (!TOKEN_OTHER_ORG) return;
65
+ const r = await request(app.server).get(`/users/${createdUserId}`)
66
+ .set(auth(TOKEN_OTHER_ORG));
67
+ expect([403, 404]).toContain(r.status);
68
+ });
69
+
70
+ it('contract: required fields present, leaked fields absent', async () => {
71
+ const r = await request(app.server).get(`/users/${createdUserId}`)
72
+ .set(auth(TOKEN_ADMIN));
73
+ expect(r.status).toBe(200);
74
+ for (const f of ['id', 'email', 'name', 'created_at']) {
75
+ expect(r.body[f]).toBeDefined();
76
+ }
77
+ for (const f of ['password_hash', 'internal_id', '_raw']) {
78
+ expect(r.body[f]).toBeUndefined();
79
+ }
80
+ });
81
+ });
82
+ ```
83
+
84
+ ## Configuration
85
+
86
+ `vitest.config.ts`:
87
+
88
+ ```ts
89
+ import { defineConfig } from 'vitest/config';
90
+ export default defineConfig({
91
+ test: {
92
+ include: ['QA/scripts/api/**/*.test.ts'],
93
+ testTimeout: 10_000,
94
+ hookTimeout: 30_000,
95
+ },
96
+ });
97
+ ```
98
+
99
+ ## Running
100
+
101
+ ```bash
102
+ # all
103
+ pnpm vitest run QA/scripts/api
104
+
105
+ # one RF
106
+ pnpm vitest run QA/scripts/api/RF-01-create-user.test.ts
107
+
108
+ # log to QA/logs/api/
109
+ pnpm vitest run QA/scripts/api 2>&1 | tee "QA/logs/api/run-$(date +%F).log"
110
+ ```
111
+
112
+ ## Logging request/response
113
+
114
+ Wrap the supertest agent in a small helper that emits to JSONL:
115
+
116
+ ```ts
117
+ import fs from 'node:fs';
118
+
119
+ const LOG = 'QA/logs/api/RF-XX-create-user.log';
120
+ fs.mkdirSync('QA/logs/api', { recursive: true });
121
+
122
+ function logRequest(method: string, url: string, status: number, ms: number, reqBody: unknown, resBody: unknown) {
123
+ fs.appendFileSync(LOG, JSON.stringify({
124
+ ts: Date.now(), method, url, status, ms, request_body: reqBody, response_body: resBody,
125
+ }) + '\n');
126
+ }
127
+ ```
128
+
129
+ ## NestJS variant
130
+
131
+ Use `@nestjs/testing`'s `Test.createTestingModule(...)` + `app.getHttpServer()` instead of `buildApp`:
132
+
133
+ ```ts
134
+ import { Test } from '@nestjs/testing';
135
+ import { AppModule } from '../../../src/app.module';
136
+
137
+ let app: INestApplication;
138
+ beforeAll(async () => {
139
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
140
+ app = moduleRef.createNestApplication();
141
+ await app.init();
142
+ });
143
+
144
+ // then: request(app.getHttpServer()) ...
145
+ ```
146
+
147
+ ## Pros / cons
148
+
149
+ - **Pro**: in-process, no port allocation, fastest possible.
150
+ - **Pro**: integrates with `vitest`/`jest` watch + coverage.
151
+ - **Pro**: `it.each` covers the 4xx matrix in one block.
152
+ - **Con**: only works against a `supertest`-compatible HTTP framework.
153
+ - **Con**: requires the `buildApp` factory pattern; one-off scripts/handlers may need refactor.
@@ -0,0 +1,138 @@
1
+ # Auth patterns — how to wire credentials into API tests
2
+
3
+ Tests need real credentials, but credentials must never live in the script files (which are committed). This file describes how each recipe handles the four common auth schemes and where credentials come from.
4
+
5
+ ## The four schemes
6
+
7
+ | Scheme | How it travels | Recipe handling |
8
+ |--------|----------------|-----------------|
9
+ | **Bearer JWT** | `Authorization: Bearer <token>` | Most common. Token comes from a login response or pre-issued for QA. |
10
+ | **Cookie session** | `Cookie: session=<sid>` (set by `Set-Cookie` on login) | Recipes capture the cookie from a login call and replay it. |
11
+ | **API key** | `X-API-Key: <key>` (header) or `?api_key=<key>` (query) | Header form is preferred; key comes from a per-environment env var. |
12
+ | **Basic auth** | `Authorization: Basic <base64(user:pass)>` | Rare in modern APIs; supported but discouraged. |
13
+
14
+ ## Where credentials come from (in priority order)
15
+
16
+ 1. **`.env` file** at the repo root, gitignored. Contains `QA_TOKEN_ADMIN`, `QA_ADMIN_EMAIL`, `QA_ADMIN_PASSWORD`, etc.
17
+ 2. **Pre-issued QA tokens** — long-lived JWTs minted by an admin tool (e.g., a `make qa-tokens` target) and stored in `.env`. Best for CI; avoids login-time flake.
18
+ 3. **Login at runtime** — a setup request hits `/auth/login` with `QA_ADMIN_EMAIL` + `QA_ADMIN_PASSWORD` and captures the token. Use when no pre-issued option exists.
19
+ 4. **`.dw/templates/qa-test-credentials.md`** — the project-level QA credentials registry that `dw-run-qa` already reads (UI mode). API mode reads the same file for env-var hints + role mapping.
20
+
21
+ ## Three roles every project should have
22
+
23
+ Even for single-tenant apps, define at minimum:
24
+
25
+ - **`token_admin`** — has every permission. Used for setup (create test data) and teardown.
26
+ - **`token_user`** — regular authenticated user. The role most happy-path tests run as.
27
+ - **`token_guest`** OR **`token_other_org_admin`** — for negative tests. In multi-tenant apps, this token belongs to a different org and powers the cross-tenant denial tests.
28
+
29
+ ## Per-recipe variable conventions
30
+
31
+ ### `.http` (REST Client)
32
+
33
+ Top of the file:
34
+
35
+ ```http
36
+ @base = {{$dotenv API_BASE_URL}}
37
+ @token_admin = {{$dotenv QA_TOKEN_ADMIN}}
38
+ @token_user = {{$dotenv QA_TOKEN_USER}}
39
+ @token_other_org = {{$dotenv QA_TOKEN_OTHER_ORG}}
40
+ ```
41
+
42
+ Or, if logging in at runtime, capture once and reuse:
43
+
44
+ ```http
45
+ ### Setup — login as admin
46
+ # @name login_admin
47
+ POST {{base}}/auth/login
48
+ Content-Type: application/json
49
+ { "email": "{{$dotenv QA_ADMIN_EMAIL}}", "password": "{{$dotenv QA_ADMIN_PASSWORD}}" }
50
+
51
+ > {%
52
+ client.global.set("token_admin", response.body.access_token);
53
+ client.test("login ok", () => client.assert(response.status === 200));
54
+ %}
55
+ ```
56
+
57
+ ### `pytest + httpx`
58
+
59
+ Read from environment in module scope; expose as fixtures if the test count grows:
60
+
61
+ ```python
62
+ TOKEN_ADMIN = os.environ["QA_TOKEN_ADMIN"]
63
+ TOKEN_USER = os.environ["QA_TOKEN_USER"]
64
+ TOKEN_OTHER_ORG = os.environ.get("QA_TOKEN_OTHER_ORG", "")
65
+
66
+ @pytest.fixture(scope="session")
67
+ async def admin_client():
68
+ async with httpx.AsyncClient(base_url=BASE,
69
+ headers={"Authorization": f"Bearer {TOKEN_ADMIN}"},
70
+ timeout=10.0) as c:
71
+ yield c
72
+ ```
73
+
74
+ ### `supertest` (Node)
75
+
76
+ Same `process.env` reads, optionally one helper per role:
77
+
78
+ ```ts
79
+ const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
80
+ const TOKEN_ADMIN = process.env.QA_TOKEN_ADMIN!;
81
+ ```
82
+
83
+ ### `WebApplicationFactory` (.NET)
84
+
85
+ Subclass the factory once per role:
86
+
87
+ ```csharp
88
+ public class AdminAppFactory : WebApplicationFactory<Program>
89
+ {
90
+ protected override void ConfigureClient(HttpClient client)
91
+ {
92
+ client.DefaultRequestHeaders.Authorization =
93
+ new AuthenticationHeaderValue("Bearer",
94
+ Environment.GetEnvironmentVariable("QA_TOKEN_ADMIN") ?? "");
95
+ }
96
+ }
97
+ ```
98
+
99
+ ### `reqwest` (Rust)
100
+
101
+ Helper functions read env once:
102
+
103
+ ```rust
104
+ fn token_admin() -> String { std::env::var("QA_TOKEN_ADMIN").unwrap_or_default() }
105
+ fn admin_client() -> reqwest::Client {
106
+ reqwest::Client::builder().build().unwrap()
107
+ }
108
+ // then: admin_client().get(url).bearer_auth(token_admin()).send().await
109
+ ```
110
+
111
+ ## Refresh tokens
112
+
113
+ If the API uses refresh tokens, capture both `access_token` and `refresh_token` in the login setup. When a test needs a long-lived flow (e.g., wait for a webhook), refresh the access token before the wait.
114
+
115
+ For most QA suites, the access token's TTL (typically 15-60 min) is longer than the suite's runtime, so refresh is unnecessary.
116
+
117
+ ## Scoped credentials per role
118
+
119
+ For RBAC-heavy systems, define more roles:
120
+
121
+ - `token_admin` — global admin
122
+ - `token_org_admin` — admin within one org
123
+ - `token_member` — regular member of one org
124
+ - `token_billing` — read-only billing access
125
+ - `token_other_org_admin` — admin of a different org (for cross-tenant tests)
126
+
127
+ Add one env var per role; the recipe reads them as needed. Tests that don't need a particular role just don't reference it.
128
+
129
+ ## Anti-patterns
130
+
131
+ - **Don't hardcode `Bearer eyJ...` in any committed file.** Even "test" tokens leak.
132
+ - **Don't share one token across happy-path AND negative tests.** If a happy-path test mutates the token's user (e.g., suspends it), every later test fails.
133
+ - **Don't reuse production tokens for QA.** Mint QA-only tokens with a clearly distinct subject (`sub: qa-admin@example.com`).
134
+ - **Don't pass credentials via command-line args.** They land in shell history and process listings.
135
+
136
+ ## What `dw-run-qa` does
137
+
138
+ In API mode, `/dw-run-qa` reads `QA/test-credentials.md` (or `.env`) for the env var names, picks the recipe, and substitutes variables at test-generation time. The script files reference `@variable` references only — never raw tokens.