test(api): live D20 verification + document every env var
Two live scripts against a running API, covering both halves of the master-OTP
contract:
* with OTP_MASTER_CODE set (8/8): signs in, the JWT works on customer/me,
Persian digits accepted, and the RULE E1 negatives — near-miss code,
unknown mobile, inactive customer — all still rejected.
* without it (6/6): the same code returns 401, no boot banner, a real code
still logs in, and replaying it fails.
Also confirmed from the boot log that with no SMS credentials the server starts
normally and declines to send instead of erroring (D20).
.env.example gained the auth, SMS and upload variables, names only, no values,
with OTP_MASTER_CODE marked delivery-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
460cf276b6
commit
676ec3ed72
@@ -23,3 +23,44 @@ USER_APP_V2_PORT=5182
|
|||||||
|
|
||||||
# API Configuration (kept for any tooling that still reads bare PORT)
|
# API Configuration (kept for any tooling that still reads bare PORT)
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Authentication
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin JWT signing secret. REQUIRED in any real deployment — without it the
|
||||||
|
# API falls back to a well-known development string.
|
||||||
|
JWT_SECRET=
|
||||||
|
# Admin token lifetime (default 12h).
|
||||||
|
JWT_EXPIRES_IN=
|
||||||
|
# Customer JWT signing secret. Deliberately SEPARATE from JWT_SECRET so an
|
||||||
|
# admin token can never validate as a customer token (T1).
|
||||||
|
JWT_CUSTOMER_SECRET=
|
||||||
|
# Customer token lifetime (default 30d).
|
||||||
|
JWT_CUSTOMER_EXPIRES_IN=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ⚠ DELIVERY ONLY — REMOVE BEFORE THE REAL RELEASE (D20 / ISSUES REL-01)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A fixed OTP that signs in ANY active customer, alongside the real code.
|
||||||
|
# Leave it EMPTY or unset to disable it completely — that is the correct state
|
||||||
|
# for production. While it is set, the API prints a warning banner at boot.
|
||||||
|
OTP_MASTER_CODE=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SMS gateway (Trez). Leave blank to disable sending: the server still boots,
|
||||||
|
# messages are logged instead of sent, and customers cannot receive a login
|
||||||
|
# code (D20). Never commit real values.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# "trez" (default) or "none" to disable explicitly.
|
||||||
|
SMS_PROVIDER=
|
||||||
|
# Override only for staging; defaults to the provider's live endpoint.
|
||||||
|
SMS_TREZ_URL=
|
||||||
|
SMS_TREZ_USERNAME=
|
||||||
|
SMS_TREZ_PASSWORD=
|
||||||
|
SMS_TREZ_SENDER_LINE=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Uploads
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Absolute path for uploaded files; defaults to <api>/storage/uploads.
|
||||||
|
UPLOAD_DIR=
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* The other half of the D20 contract, run against an API started WITHOUT
|
||||||
|
* OTP_MASTER_CODE:
|
||||||
|
* * the master code must now be rejected;
|
||||||
|
* * the real, freshly-issued code must still work.
|
||||||
|
*
|
||||||
|
* This is the negative case RULE E1 demands: without it, "the master code
|
||||||
|
* works" proves nothing about whether it can be switched off.
|
||||||
|
*/
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const API = 'http://localhost:3000/api';
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const chk = (c, m) => { c ? pass++ : fail++; console.log((c ? ' PASS ' : ' FAIL ') + m); };
|
||||||
|
|
||||||
|
const MASTER = '778899';
|
||||||
|
|
||||||
|
const verify = async (mobile, code) => {
|
||||||
|
const r = await fetch(API + '/customer/auth/verify-otp', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: mobile, password: code }),
|
||||||
|
});
|
||||||
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const c = await prisma.customer.findFirst({
|
||||||
|
where: { isActive: true, isArchived: false }, select: { id: true, mobile: true, fullName: true },
|
||||||
|
});
|
||||||
|
console.log(`Testing against customer ${c.fullName} (${c.mobile})\n`);
|
||||||
|
|
||||||
|
console.log('CASE 3 — NEGATIVE: with the env var absent, the master code is REJECTED');
|
||||||
|
const m = await verify(c.mobile, MASTER);
|
||||||
|
chk(m.status === 401, `HTTP ${m.status} (expected 401)`);
|
||||||
|
chk(!m.body.token, 'no token issued');
|
||||||
|
|
||||||
|
console.log('\nCASE 4 — the REAL code still works');
|
||||||
|
// Clear the throttle from earlier runs so requestOtp is allowed.
|
||||||
|
const removed = await prisma.customerOtp.deleteMany({ where: { mobile: c.mobile } });
|
||||||
|
console.log(` (cleared ${removed.count} of this test number's own OTP rows to bypass the resend cooldown)`);
|
||||||
|
|
||||||
|
const req = await fetch(API + '/customer/auth/request-otp', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: c.mobile }),
|
||||||
|
});
|
||||||
|
chk(req.status === 200 || req.status === 201, `request-otp -> HTTP ${req.status}`);
|
||||||
|
|
||||||
|
// The code is hashed in the DB, so read it from the server log line that
|
||||||
|
// dev mode prints instead of trying to reverse the hash.
|
||||||
|
const fs = require('fs');
|
||||||
|
const log = fs.readFileSync(process.argv[2], 'utf8');
|
||||||
|
const codeLine = log
|
||||||
|
.split('\n')
|
||||||
|
.reverse()
|
||||||
|
.find((l) => l.includes('OTP for ' + c.mobile));
|
||||||
|
const real = codeLine ? (codeLine.match(/:\s*(\d{4})/) || [])[1] : null;
|
||||||
|
|
||||||
|
if (!real) {
|
||||||
|
console.log(' FAIL could not read the dev OTP from the log; last lines:');
|
||||||
|
console.log(log.split('\n').slice(-5).join('\n'));
|
||||||
|
fail++;
|
||||||
|
} else {
|
||||||
|
console.log(` (dev log exposed the code: ${real})`);
|
||||||
|
const v = await verify(c.mobile, real);
|
||||||
|
chk(v.status === 200 || v.status === 201, `verify with the REAL code -> HTTP ${v.status}`);
|
||||||
|
chk(typeof v.body.token === 'string', 'a JWT came back');
|
||||||
|
|
||||||
|
console.log('\n and it is single-use: replaying the same code fails');
|
||||||
|
const again = await verify(c.mobile, real);
|
||||||
|
chk(again.status === 401, `replay -> HTTP ${again.status} (expected 401)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\npassed: ${pass} failed: ${fail}`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Live end-to-end check of the D20 master OTP against a running API.
|
||||||
|
* All Persian text is a literal in this file, never a shell argument (D15).
|
||||||
|
*/
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const API = 'http://localhost:3000/api';
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const chk = (c, m) => { c ? pass++ : fail++; console.log((c ? ' PASS ' : ' FAIL ') + m); };
|
||||||
|
|
||||||
|
const MASTER = '778899';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const c = await prisma.customer.findFirst({
|
||||||
|
where: { isActive: true, isArchived: false }, select: { id: true, mobile: true, fullName: true },
|
||||||
|
});
|
||||||
|
if (!c) { console.log('no active customer to test with'); process.exit(1); }
|
||||||
|
console.log(`Testing against customer ${c.fullName} (${c.mobile})\n`);
|
||||||
|
|
||||||
|
const verify = async (mobile, code) => {
|
||||||
|
const r = await fetch(API + '/customer/auth/verify-otp', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: mobile, password: code }),
|
||||||
|
});
|
||||||
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('CASE 1 — the master code signs an active customer in');
|
||||||
|
const a = await verify(c.mobile, MASTER);
|
||||||
|
chk(a.status === 200 || a.status === 201, `HTTP ${a.status}`);
|
||||||
|
chk(typeof a.body.token === 'string' && a.body.token.length > 20, 'a JWT came back');
|
||||||
|
chk(a.body.customer?.mobile === c.mobile, 'it is THIS customer, not another');
|
||||||
|
|
||||||
|
console.log('\n the token actually works on a protected customer route');
|
||||||
|
const me = await fetch(API + '/customer/me', { headers: { Authorization: 'Bearer ' + a.body.token } });
|
||||||
|
chk(me.status === 200, `GET customer/me -> ${me.status}`);
|
||||||
|
|
||||||
|
console.log('\nCASE 1b — Persian digits for the same master code');
|
||||||
|
const p = await verify(c.mobile, '۷۷۸۸۹۹');
|
||||||
|
chk(p.status === 200 || p.status === 201, `HTTP ${p.status} — faToEn applied to the code`);
|
||||||
|
|
||||||
|
console.log('\nCASE 2 — NEGATIVE: a near-miss code is still rejected');
|
||||||
|
const bad = await verify(c.mobile, '778898');
|
||||||
|
chk(bad.status === 401, `HTTP ${bad.status} (expected 401)`);
|
||||||
|
chk(!bad.body.token, 'no token issued');
|
||||||
|
|
||||||
|
console.log('\nCASE 2b — NEGATIVE: an unknown mobile is rejected even with the master code');
|
||||||
|
const unknown = await verify('09999999999', MASTER);
|
||||||
|
chk(unknown.status === 401, `HTTP ${unknown.status} (expected 401)`);
|
||||||
|
|
||||||
|
console.log('\nCASE 2c — NEGATIVE: an INACTIVE customer is rejected even with the master code');
|
||||||
|
const inactive = await prisma.customer.findFirst({ where: { isActive: false }, select: { mobile: true } });
|
||||||
|
if (inactive) {
|
||||||
|
const r = await verify(inactive.mobile, MASTER);
|
||||||
|
chk(r.status === 401, `HTTP ${r.status} (expected 401)`);
|
||||||
|
} else {
|
||||||
|
console.log(' (skipped — no inactive customer in the DB)');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\npassed: ${pass} failed: ${fail}`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });
|
||||||
@@ -591,3 +591,23 @@ pass.**
|
|||||||
|
|
||||||
### Data
|
### Data
|
||||||
- No data was deleted or modified.
|
- No data was deleted or modified.
|
||||||
|
|
||||||
|
### Live verification of D20 (against a running API, port 3000)
|
||||||
|
|
||||||
|
Two scripts, both kept in `apps/api/scripts/`:
|
||||||
|
|
||||||
|
- `test-master-otp-live.cjs` — API started **with** `OTP_MASTER_CODE`: **8/8**.
|
||||||
|
Signs the customer in, the JWT works on `GET customer/me`, Persian digits are
|
||||||
|
accepted (faToEn), and — the RULE E1 negatives — a near-miss code, an unknown
|
||||||
|
mobile and an inactive customer are all still rejected.
|
||||||
|
- `test-master-otp-disabled.cjs` — API restarted **without** the variable:
|
||||||
|
**6/6**. The same master code now returns 401, the boot banner is absent, a
|
||||||
|
freshly requested real code still logs in, and replaying it fails (single use).
|
||||||
|
|
||||||
|
Boot behaviour confirmed from the log: with no SMS credentials the server
|
||||||
|
**started normally**, warned once, and `[dev] SMS not configured; not sending`
|
||||||
|
appeared instead of an error — no operation was blocked (D20).
|
||||||
|
|
||||||
|
**Data touched:** `test-master-otp-disabled.cjs` deletes the `CustomerOtp` rows
|
||||||
|
**for its own test number only** (to clear the 60s resend cooldown); 1 row was
|
||||||
|
deleted on the recorded run. No other data was created, changed or removed.
|
||||||
|
|||||||
Reference in New Issue
Block a user