/** * "تکمیل پروفایل" — POST customer/me/complete-profile and its uploads. * * Ports henza_server Major/CustomerController::complete * (app/Http/Controllers/Major/CustomerController.php:73-108). * * Covers what the owner asked for per page — opening, the empty state, the * error state and the main flow — plus the boundaries that matter here: * * 1. the main flow: three uploads, then complete, then VERIFIED * 2. empty/validation: each id missing in turn is a 400/422 * 3. error: an id that names no file is refused, and nothing is written * 4. the customer comes from the TOKEN — no id is accepted from the body * 5. no token at all is a 401 on both routes * 6. an expert can complete their own profile too (D21: every customer * route works for an expert unchanged) * 7. SEC-14: extra body fields are IGNORED, not mass-assigned. The * reference did `$customer->update($request->all())`, which let the app * write gram_level/percent/active onto itself. * * Every Persian string is a literal here and travels as UTF-8 JSON (D15). * * Requires the API running WITH OTP_MASTER_CODE set. * Usage: node scripts/test-complete-profile.cjs [apiBaseUrl] [masterCode] */ const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, ''); const MASTER = process.argv[3] || '1379'; let pass = 0, fail = 0; const failures = []; const chk = (c, m) => { if (c) { pass++; console.log(' PASS ' + m); } else { fail++; failures.push(m); console.log(' FAIL ' + m); } }; const step = (t) => console.log('\n' + t); const call = async (method, p, body, token) => { const res = await fetch(`${API}/${p}`, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await res.text(); let payload = null; try { payload = JSON.parse(text); } catch { /* empty */ } return { status: res.status, ok: res.ok, body: payload, raw: text }; }; /** A one-pixel PNG, so the upload exercises the real MIME whitelist. */ const PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ); const upload = async (token, purpose) => { const form = new FormData(); form.append('file', new Blob([PNG], { type: 'image/png' }), `${purpose}.png`); form.append('purpose', purpose); const res = await fetch(`${API}/customer/me/documents`, { method: 'POST', headers: token ? { Authorization: 'Bearer ' + token } : {}, body: form, }); const text = await res.text(); let payload = null; try { payload = JSON.parse(text); } catch { /* empty */ } return { status: res.status, ok: res.ok, body: payload, raw: text }; }; const signIn = (mobile) => call('POST', 'customer/auth/verify-otp', { username: mobile, password: MASTER }); /** Everything this run creates, removed at the end. */ const made = { admins: [], customers: [], files: [] }; const uniq = String(Date.now()).slice(-7); (async () => { step('SETUP — an unverified customer, and a sales expert'); const mkCustomer = async (suffix, name) => { const c = await prisma.customer.create({ data: { fullName: name, firstName: name.split(' ')[0], lastName: name.split(' ').slice(1).join(' '), mobile: `0917${uniq.slice(0, 3)}${suffix}`, nationalCode: `nc${uniq}${suffix}`, passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true, goldCreditLimitGrams: '100', verificationStep: 'UNVERIFIED', }, }); made.customers.push(c.id); return c; }; const cust = await mkCustomer('11', 'مشتری مدارک'); console.log(` customer: ${cust.fullName} (${cust.mobile}) step=${cust.verificationStep}`); const expertAdmin = await prisma.adminUser.create({ data: { fullName: 'کارشناس مدارک', firstName: 'کارشناس', lastName: 'مدارک', mobile: `0918${uniq.slice(0, 3)}22`, passwordHash: '!NO_PASSWORD_SET_YET', role: 'SALES_EXPERT', isActive: true, wagePercent: '3', }, }); await prisma.salesExpertProfile.create({ data: { adminUserId: expertAdmin.id, gramLevel: '120' }, }); made.admins.push(expertAdmin.id); // ── 5. NO TOKEN ───────────────────────────────────────────────────────── step('5 — both routes require a session'); const anonComplete = await call('POST', 'customer/me/complete-profile', { business_license_image_id: 'x', shop_image_id: 'x', ncode_image_id: 'x', }); chk(anonComplete.status === 401, `complete-profile with no token -> ${anonComplete.status} (expected 401)`); const anonUpload = await upload(null, 'business-license'); chk(anonUpload.status === 401, `documents with no token -> ${anonUpload.status} (expected 401)`); // ── SIGN IN ───────────────────────────────────────────────────────────── step('SIGN IN — the customer, through the ordinary app flow'); await call('POST', 'customer/auth/request-otp', { username: cust.mobile }); const login = await signIn(cust.mobile); chk(login.ok, `verify-otp -> ${login.status}`); const token = login.body?.token; chk(Boolean(token), 'an app token came back'); // The page opens against /customer/me, which is what the router guard reads. const me = await call('GET', 'customer/me', undefined, token); chk(me.ok, `GET customer/me -> ${me.status}`); chk(me.body?.verificationStep === 'UNVERIFIED', `the page is reachable: verificationStep is ${me.body?.verificationStep}`); // ── 2. VALIDATION — the empty state ───────────────────────────────────── step('2 — EMPTY STATE: each document is required'); const ids = { business_license_image_id: 'a', shop_image_id: 'b', ncode_image_id: 'c' }; for (const key of Object.keys(ids)) { const partial = { ...ids }; delete partial[key]; const r = await call('POST', 'customer/me/complete-profile', partial, token); chk(r.status === 400 || r.status === 422, `missing ${key} -> ${r.status} (expected 400/422)`); } const allEmpty = await call('POST', 'customer/me/complete-profile', {}, token); chk(allEmpty.status === 400 || allEmpty.status === 422, `an empty body -> ${allEmpty.status} (expected 400/422)`); const stillUnverified = await prisma.customer.findUnique({ where: { id: cust.id } }); chk(stillUnverified.verificationStep === 'UNVERIFIED', 'a rejected submission left the verification step untouched'); // ── 3. ERROR STATE — an id that names no file ─────────────────────────── step('3 — ERROR STATE: an unknown document id is refused'); const bogus = await call('POST', 'customer/me/complete-profile', { business_license_image_id: 'no-such-file-id', shop_image_id: 'no-such-file-id', ncode_image_id: 'no-such-file-id', }, token); chk(bogus.status === 400, `unknown ids -> ${bogus.status} (expected 400)`); const afterBogus = await prisma.customer.findUnique({ where: { id: cust.id } }); chk(afterBogus.verificationStep === 'UNVERIFIED', 'and nothing was written: still UNVERIFIED'); chk(afterBogus.businessLicenseUrl === null, 'and no document URL was stored'); // ── 1. MAIN FLOW ──────────────────────────────────────────────────────── step('1 — MAIN FLOW: upload three documents, then complete'); const licence = await upload(token, 'business-license'); chk(licence.ok, `upload the business licence -> ${licence.status}`); const shop = await upload(token, 'shop-image'); chk(shop.ok, `upload the shop front -> ${shop.status}`); const ncode = await upload(token, 'national-card'); chk(ncode.ok, `upload the national card -> ${ncode.status}`); for (const u of [licence, shop, ncode]) if (u.body?.id) made.files.push(u.body.id); chk(Boolean(licence.body?.id && licence.body?.url), 'each upload answers with an id and a stored url'); chk(licence.body?.uploadedByAdminUserId === null, 'a customer upload is not attributed to an admin user'); const done = await call('POST', 'customer/me/complete-profile', { business_license_image_id: licence.body.id, shop_image_id: shop.body.id, ncode_image_id: ncode.body.id, }, token); chk(done.ok, `complete-profile -> ${done.status}`); const after = await prisma.customer.findUnique({ where: { id: cust.id } }); chk(after.verificationStep === 'VERIFIED', `the step moved to the reference's live value (VERIFIED), got ${after.verificationStep}`); chk(after.businessLicenseUrl === licence.body.url, 'the business licence url was stored'); chk(after.shopImageUrl === shop.body.url, 'the shop front url was stored'); chk(after.nationalIdImageUrl === ncode.body.url, 'the national card url was stored'); // The router guard must now keep the page shut. const meAfter = await call('GET', 'customer/me', undefined, token); chk(meAfter.body?.verificationStep === 'VERIFIED', 'the app now reads VERIFIED, so the guard redirects the page home'); // ── 7. SEC-14 — extra fields are ignored, not mass-assigned ───────────── step('7 — SEC-14: extra body fields are IGNORED, not written to the row'); const before = await prisma.customer.findUnique({ where: { id: cust.id } }); const inject = await call('POST', 'customer/me/complete-profile', { business_license_image_id: licence.body.id, shop_image_id: shop.body.id, ncode_image_id: ncode.body.id, // Every one of these was writable through the reference's $request->all(). goldCreditLimitGrams: '999999', gram_level: '999999', goldDebtGrams: '-500', makingChargeOffsetPercent: '0', isActive: false, mobile: '09000000000', }, token); chk(inject.status === 400 || inject.ok, `a body with extra fields -> ${inject.status}`); const poked = await prisma.customer.findUnique({ where: { id: cust.id } }); chk(String(poked.goldCreditLimitGrams) === String(before.goldCreditLimitGrams), `the credit limit is unchanged (${poked.goldCreditLimitGrams})`); chk(String(poked.goldDebtGrams) === String(before.goldDebtGrams), `the gold debt is unchanged (${poked.goldDebtGrams})`); chk(String(poked.makingChargeOffsetPercent) === String(before.makingChargeOffsetPercent), `the making charge is unchanged (${poked.makingChargeOffsetPercent})`); chk(poked.isActive === before.isActive, 'isActive is unchanged'); chk(poked.mobile === before.mobile, `the mobile is unchanged (${poked.mobile})`); // ── 4. THE SUBJECT COMES FROM THE TOKEN ───────────────────────────────── step('4 — the customer comes from the TOKEN, never from the body'); const victim = await mkCustomer('44', 'مشتری دیگر'); const victimBefore = await prisma.customer.findUnique({ where: { id: victim.id } }); chk(victimBefore.verificationStep === 'UNVERIFIED', 'the second customer starts UNVERIFIED'); await call('POST', 'customer/me/complete-profile', { business_license_image_id: licence.body.id, shop_image_id: shop.body.id, ncode_image_id: ncode.body.id, // Both spellings, in case either were read. customerId: victim.id, customer_id: victim.id, id: victim.id, }, token); const victimAfter = await prisma.customer.findUnique({ where: { id: victim.id } }); chk(victimAfter.verificationStep === 'UNVERIFIED', "naming another customer in the body did NOT touch their row"); chk(victimAfter.businessLicenseUrl === null, 'and stored no document against them'); // ── 6. AN EXPERT CAN COMPLETE THEIR OWN PROFILE ───────────────────────── step('6 — D21: the same page works for a sales expert'); await call('POST', 'customer/auth/request-otp', { username: expertAdmin.mobile }); const expLogin = await signIn(expertAdmin.mobile); chk(expLogin.ok, `the expert signs in -> ${expLogin.status}`); const expToken = expLogin.body?.token; const prof = await prisma.salesExpertProfile.findUnique({ where: { adminUserId: expertAdmin.id } }); if (prof?.tradingCustomerId) made.customers.push(prof.tradingCustomerId); const expUpload = await upload(expToken, 'business-license'); chk(expUpload.ok, `the expert uploads a document -> ${expUpload.status}`); if (expUpload.body?.id) made.files.push(expUpload.body.id); const expDone = await call('POST', 'customer/me/complete-profile', { business_license_image_id: expUpload.body.id, shop_image_id: expUpload.body.id, ncode_image_id: expUpload.body.id, }, expToken); chk(expDone.ok, `the expert completes their profile -> ${expDone.status}`); const expTrading = prof?.tradingCustomerId ? await prisma.customer.findUnique({ where: { id: prof.tradingCustomerId } }) : null; chk(expTrading?.verificationStep === 'VERIFIED', 'it landed on the expert\'s own trading account, not on anyone else'); // ── CLEANUP ───────────────────────────────────────────────────────────── step('CLEANUP'); await prisma.uploadedFile.deleteMany({ where: { id: { in: made.files } } }); await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } }); await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: made.admins } } }); await prisma.customer.deleteMany({ where: { id: { in: made.customers } } }); await prisma.adminUser.deleteMany({ where: { id: { in: made.admins } } }); console.log( ` deleted ${made.files.length} uploaded-file row(s), ${made.customers.length} customer(s) ` + `(including the expert trading account this run created) and ${made.admins.length} admin user(s). ` + 'All were created by this run; no pre-existing data was touched. ' + 'The uploaded blobs themselves are left on disk, as every other test does.', ); console.log(`\n${'='.repeat(60)}`); console.log(`COMPLETE PROFILE: passed ${pass} failed ${fail}`); if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); } console.log('='.repeat(60)); await prisma.$disconnect(); process.exit(fail ? 1 : 0); })().catch(async (e) => { console.error('\nABORTED:', e); await prisma.$disconnect(); process.exit(1); });