feat: implement OTP login flow for non-staff accounts in QA driver

This commit is contained in:
hamed
2026-07-19 13:58:20 +03:30
parent b10b0813f3
commit 3e841b5e57
2 changed files with 69 additions and 3 deletions
+50 -3
View File
@@ -16,7 +16,7 @@
* into localStorage, navigates, then reports console errors, failed network
* requests, the path it actually landed on, and a screenshot.
*/
import { spawn } from 'node:child_process';
import { spawn, execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
@@ -66,6 +66,43 @@ function creds(role) {
throw new Error(`unknown role "${role}". Known: ${Object.keys(ROLES).join(', ')}`);
}
/**
* Non-staff accounts (ROLE_USER, ROLE_UNCLAIMED_DOCTOR) are rejected by
* PasswordAuthenticator with ERR_AUTH_006 by design — they are OTP-only.
* In dev the OTP is the fixed '12345' (OtpService::sendCode), so the whole
* send-code → verify-code → otp-login chain is scriptable.
*/
async function otpLogin(mobile) {
const post = async (path, body) => {
const r = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json();
};
const sent = await post('/api/v1/user/send-code', { mobile });
if (!sent.uuid) throw new Error(`send-code failed: ${JSON.stringify(sent).slice(0, 200)}`);
const ver = await post('/api/v1/user/verify-code', { uuid: sent.uuid, code: '12345' });
const grant = ver?.data?.grant;
if (!grant) throw new Error(`verify-code failed: ${JSON.stringify(ver).slice(0, 200)}`);
return post('/api/v1/user/otp-login', { grant });
}
/** Same signing key and claims as a real login — only skips the rate limiter. */
function mintToken(mobile) {
const out = execSync(
`ddev exec 'php bin/console lexik:jwt:generate-token ${mobile} --user-class="App\\\\Auth\\\\Entity\\\\User"'`,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd() },
);
const tok = out.trim().split('\n').pop().trim();
if (!tok.startsWith('ey')) throw new Error(`could not mint token for ${mobile}`);
return tok;
}
async function login(role) {
const [mobile_number, password] = creds(role);
const r = await fetch(`${BASE}/api/v1/user/login`, {
@@ -74,8 +111,18 @@ async function login(role) {
body: JSON.stringify({ mobile_number, password }),
});
const j = await r.json();
if (!j.access_token) throw new Error(`login as ${role} failed: ${JSON.stringify(j).slice(0, 300)}`);
return j;
if (j.access_token) return j;
// ERR_AUTH_006 here means "staff-only endpoint", not "wrong password".
if (j?.errors?.some((e) => e.code === 'ERR_AUTH_006')) {
const o = await otpLogin(mobile_number).catch((e) => ({ _err: e.message }));
if (o.access_token) return o;
// send-code is capped at 5/hour/IP; once burned, mint the JWT with the app's
// own command rather than loosening a real product limit for a test.
return { access_token: mintToken(mobile_number) };
}
throw new Error(`login as ${role} failed: ${JSON.stringify(j).slice(0, 300)}`);
}
/** JWT is unsigned-read here purely to report which roles a token carries. */