@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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/scaffold/en/commands/dw-fix-qa.md +34 -13
- package/scaffold/en/commands/dw-run-qa.md +124 -23
- package/scaffold/pt-br/commands/dw-fix-qa.md +34 -13
- package/scaffold/pt-br/commands/dw-run-qa.md +124 -23
- package/scaffold/skills/api-testing-recipes/SKILL.md +104 -0
- package/scaffold/skills/api-testing-recipes/recipes/dotnet-webapp-factory.md +168 -0
- package/scaffold/skills/api-testing-recipes/recipes/http-rest-client.md +130 -0
- package/scaffold/skills/api-testing-recipes/recipes/pytest-httpx.md +157 -0
- package/scaffold/skills/api-testing-recipes/recipes/rust-reqwest.md +173 -0
- package/scaffold/skills/api-testing-recipes/recipes/supertest-node.md +153 -0
- package/scaffold/skills/api-testing-recipes/references/auth-patterns.md +138 -0
- package/scaffold/skills/api-testing-recipes/references/log-conventions.md +117 -0
- package/scaffold/skills/api-testing-recipes/references/matrix-conventions.md +68 -0
- package/scaffold/skills/api-testing-recipes/references/openapi-driven.md +97 -0
|
@@ -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.
|