diff --git a/apps/api/src/modules/customer-auth/act-as-customer.spec.ts b/apps/api/src/modules/customer-auth/act-as-customer.spec.ts new file mode 100644 index 0000000..52208eb --- /dev/null +++ b/apps/api/src/modules/customer-auth/act-as-customer.spec.ts @@ -0,0 +1,184 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { AdminRole } from '@prisma/client'; + +// passport-jwt reads the secret at construction; give it something to find. +process.env.JWT_CUSTOMER_SECRET = 'test-secret'; + +// @nestjs/jwt v12 is ESM only and this jest setup is CommonJS. The strategy +// pulls it in transitively (customer-auth.service exports customerJwtSecret), +// so it is stubbed here exactly as master-otp.spec.ts does. +jest.mock('@nestjs/jwt', () => ({ JwtService: class {} })); + +import { CustomerJwtStrategy } from './customer-jwt.strategy'; +import { + ACT_AS_CUSTOMER_HEADER, + CUSTOMER_TOKEN_TYPE, + EXPERT_TOKEN_TYPE, +} from './app-principal'; + +/** + * «ورود به حساب مشتری» — a sales expert working inside one of their own + * customers' accounts (owner request 2026-10-12). + * + * The reference did this by handing the expert the customer's own permanent + * `api_token` (ApiMobileController.php:197-201), which meant the request was + * literally the customer's: unauditable, valid forever, and still working after + * the expert left. Here the expert keeps their own token and names the customer + * in a header, and this strategy is the ONLY place that decides whether that is + * allowed — so these cases are the whole security boundary of the feature. + * + * The strategy had no test coverage at all before this file. + */ +describe('CustomerJwtStrategy — a sales expert acting as their customer', () => { + const EXPERT = { + id: 'adm_1', + mobile: '09120000001', + role: AdminRole.SALES_EXPERT, + isActive: true, + salesExpertProfile: { + tradingCustomerId: 'cus_trading', + tradingCustomer: { id: 'cus_trading', isActive: true, isArchived: false }, + }, + }; + + /** The expert's own referred customer, as the scoped lookup would find them. */ + const MINE = { id: 'cus_mine', mobile: '09125555555' }; + + function build(opts: { admin?: unknown; scopedCustomer?: unknown; customer?: unknown } = {}) { + const prisma = { + adminUser: { findUnique: jest.fn().mockResolvedValue('admin' in opts ? opts.admin : EXPERT) }, + customer: { + // The act-as lookup: id AND referredByAgentId in one where clause, so + // "not mine" and "does not exist" are the same answer. + findFirst: jest.fn().mockResolvedValue('scopedCustomer' in opts ? opts.scopedCustomer : MINE), + findUnique: jest.fn().mockResolvedValue( + 'customer' in opts + ? opts.customer + : { id: 'cus_1', mobile: '09121111111', isActive: true, isArchived: false }, + ), + }, + }; + // Session tracking off — isActive() answers true without a query. + const sessions = { isActive: jest.fn().mockResolvedValue(true) }; + const strategy = new CustomerJwtStrategy(prisma as never, sessions as never); + return { strategy, prisma }; + } + + const req = (actAs?: string) => + ({ headers: actAs === undefined ? {} : { [ACT_AS_CUSTOMER_HEADER]: actAs } }) as never; + + const expertToken = { sub: 'adm_1', mobile: '09120000001', typ: EXPERT_TOKEN_TYPE } as never; + const customerToken = { sub: 'cus_1', mobile: '09121111111', typ: CUSTOMER_TOKEN_TYPE } as never; + + it('serves the customer as themselves, keeping the expert as the real actor', async () => { + const { strategy } = build(); + const me = await strategy.validate(req('cus_mine'), expertToken); + + // `id` is what every customer route reads — this is the whole feature. + expect(me.id).toBe('cus_mine'); + expect(me.actingAsCustomerId).toBe('cus_mine'); + // The real actor is never lost, which is what the reference could not do. + expect(me.adminUserId).toBe('adm_1'); + expect(me.type).toBe(EXPERT_TOKEN_TYPE); + }); + + it('scopes the lookup to this expert in the query itself', async () => { + const { strategy, prisma } = build(); + await strategy.validate(req('cus_mine'), expertToken); + expect(prisma.customer.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'cus_mine', + referredByAgentId: 'adm_1', + isActive: true, + isArchived: false, + }), + }), + ); + }); + + it("refuses another expert's customer with 403, not 401", async () => { + // The scoped lookup finds nothing — same answer as a non-existent id, so an + // id cannot be probed for existence. + const { strategy } = build({ scopedCustomer: null }); + // 403 and not 401 on purpose: the expert's own session is fine, so the app + // clears the act-as state rather than logging them out. + await expect(strategy.validate(req('cus_someone_else'), expertToken)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('refuses an inactive or archived customer', async () => { + // isActive/isArchived are part of the same where clause, so this is the + // identical "no row" path; asserted separately because it is a distinct + // business case that must not quietly start working. + const { strategy } = build({ scopedCustomer: null }); + await expect(strategy.validate(req('cus_disabled'), expertToken)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('cuts the expert off immediately once they are deactivated', async () => { + const { strategy } = build({ admin: { ...EXPERT, isActive: false } }); + await expect(strategy.validate(req('cus_mine'), expertToken)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('cuts them off immediately once they are no longer a sales expert', async () => { + const { strategy } = build({ admin: { ...EXPERT, role: AdminRole.ACCOUNTANT } }); + await expect(strategy.validate(req('cus_mine'), expertToken)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('ignores the header on an ordinary customer token', async () => { + // Not an error — simply no effect. A customer cannot grant themselves + // anything by sending it, and there is nothing for them to discover. + const { strategy, prisma } = build(); + const me = await strategy.validate(req('cus_mine'), customerToken); + expect(me.id).toBe('cus_1'); + expect(me.actingAsCustomerId).toBeUndefined(); + expect(prisma.customer.findFirst).not.toHaveBeenCalled(); + }); + + it('falls back to the trading account when no header is sent', async () => { + const { strategy, prisma } = build(); + const me = await strategy.validate(req(), expertToken); + expect(me.id).toBe('cus_trading'); + expect(me.actingAsCustomerId).toBeUndefined(); + // No extra query on the ordinary path — the act-as lookup never runs. + expect(prisma.customer.findFirst).not.toHaveBeenCalled(); + }); + + it('treats a blank or whitespace header as absent', async () => { + const { strategy } = build(); + await expect(strategy.validate(req(' '), expertToken)).resolves.toMatchObject({ + id: 'cus_trading', + }); + }); + + it('takes the first value when the header is sent twice', async () => { + const { strategy, prisma } = build(); + const dup = { headers: { [ACT_AS_CUSTOMER_HEADER]: ['cus_mine', 'cus_other'] } } as never; + await strategy.validate(dup, expertToken); + expect(prisma.customer.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ id: 'cus_mine' }) }), + ); + }); + + it('refuses a revoked session before looking at the header at all', async () => { + const prisma = { + adminUser: { findUnique: jest.fn() }, + customer: { findFirst: jest.fn(), findUnique: jest.fn() }, + }; + const strategy = new CustomerJwtStrategy( + prisma as never, + { isActive: jest.fn().mockResolvedValue(false) } as never, + ); + await expect(strategy.validate(req('cus_mine'), expertToken)).rejects.toThrow( + UnauthorizedException, + ); + expect(prisma.adminUser.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/customer-auth/app-principal.ts b/apps/api/src/modules/customer-auth/app-principal.ts index 5b483d5..addab53 100644 --- a/apps/api/src/modules/customer-auth/app-principal.ts +++ b/apps/api/src/modules/customer-auth/app-principal.ts @@ -54,13 +54,37 @@ export interface AppJwtPayload { * Customer.referredByAgentId points at the AdminUser, not at the trading row. */ export interface AuthenticatedCustomer { - /** Customer id. For an expert, their own trading account. */ + /** + * Customer id. For an expert: their own trading account, or — while acting as + * one of their customers — that customer. Every customer route reads this, so + * setting it here is what makes the whole app render from the customer's point + * of view without touching a single route. + */ id: string; mobile: string; /** Which kind of principal this is. */ type: AppTokenType; /** The AdminUser id — present only for a sales expert. */ adminUserId?: string; + /** + * Set only while a sales expert is acting as one of their referred customers + * («ورود به حساب مشتری»). Equal to `id` when set. + * + * It exists so the real actor is never lost: `id` says whose data this is, + * `adminUserId` says who is actually driving. The reference had no equivalent + * — it handed the expert the customer's own `api_token`, so a request was + * indistinguishable from the customer's own and nothing could be audited + * afterwards. + */ + actingAsCustomerId?: string; +} + +/** The request header that asks to act as one of the expert's own customers. */ +export const ACT_AS_CUSTOMER_HEADER = 'x-act-as-customer'; + +/** True when this principal is a sales expert acting as one of their customers. */ +export function isActingAsCustomer(me: AuthenticatedCustomer): boolean { + return Boolean(me.actingAsCustomerId); } /** True when this principal is a sales expert. */ diff --git a/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts b/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts index 801a0dd..0dfc642 100644 --- a/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts +++ b/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts @@ -1,10 +1,12 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { AdminRole } from '@prisma/client'; +import type { Request } from 'express'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { PrismaService } from '../../prisma/prisma.service'; import { customerJwtSecret } from './customer-auth.service'; import { + ACT_AS_CUSTOMER_HEADER, CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type AppJwtPayload, @@ -13,8 +15,10 @@ import { import { AppSessionService } from '../sessions/app-session.service'; export { + ACT_AS_CUSTOMER_HEADER, CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, + isActingAsCustomer, type AuthenticatedCustomer, } from './app-principal'; @@ -50,10 +54,13 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: customerJwtSecret(), + // Needed only to read the act-as header; the token itself is still the + // only thing that establishes WHO is calling. + passReqToCallback: true, }); } - async validate(payload: AppJwtPayload): Promise { + async validate(req: Request, payload: AppJwtPayload): Promise { // نشست باطل‌شده (خروج سراسری از IdP یا ابطال محلی). در حالت خاموشِ ردیابی، // بدون هیچ کوئری true برمی‌گردد ⇒ هزینه و رفتار دقیقاً مثل قبل. if (!(await this.sessions.isActive(payload?.jti))) { @@ -61,7 +68,7 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw } if (payload?.typ === EXPERT_TOKEN_TYPE) { - return this.validateExpert(payload); + return this.validateExpert(payload, this.actAsHeader(req)); } // A token that is not explicitly a customer token is refused, whatever @@ -84,6 +91,17 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw return { id: customer.id, mobile: customer.mobile, type: CUSTOMER_TOKEN_TYPE }; } + /** + * The act-as header, or null. Deliberately read ONLY for an expert token: on + * a customer's token the header has no effect at all, rather than being an + * error — a customer cannot grant themselves anything by sending it. + */ + private actAsHeader(req: Request): string | null { + const raw = (req?.headers as Record | undefined)?.[ACT_AS_CUSTOMER_HEADER]; + const value = Array.isArray(raw) ? raw[0] : raw; + return typeof value === 'string' && value.trim() ? value.trim() : null; + } + /** * A sales expert. `sub` is their AdminUser id, so the role and active flag * are re-checked on every request: demoting an expert or deactivating them @@ -91,8 +109,28 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw * * The trading Customer row is resolved here rather than stored in the token, * so a token issued before that row existed keeps working. + * + * ACTING AS A CUSTOMER («ورود به حساب مشتری», owner request 2026-10-12) + * + * With `actAs` set, the returned principal's `id` is that customer, so every + * customer route — orders, me, ledger, report, favourites, documents — serves + * the customer's own data with no change to any route. That is the whole + * feature, and it is why this is the only place the behaviour lives. + * + * How this differs from the reference, which did the same thing by handing the + * expert the customer's own `api_token` (ApiMobileController.php:197-201): + * + * - no token is minted, so nothing durable leaks into a device's storage + * and ending the app session ends the impersonation; + * - ownership is re-checked on EVERY request, so moving a customer to + * another expert cuts access off at once instead of at token expiry; + * - `adminUserId` survives, so the real actor is auditable — in the + * reference the request was indistinguishable from the customer's own. */ - private async validateExpert(payload: AppJwtPayload): Promise { + private async validateExpert( + payload: AppJwtPayload, + actAs: string | null, + ): Promise { const admin = await this.prisma.adminUser.findUnique({ where: { id: payload.sub }, select: { @@ -113,6 +151,36 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw throw new UnauthorizedException(); } + if (actAs) { + // Scoped by referredByAgentId in the same query as the id, so another + // expert's customer and a non-existent id are the same answer: no row, + // 403. The lookup cannot be talked out of its scope. + const customer = await this.prisma.customer.findFirst({ + where: { + id: actAs, + referredByAgentId: admin.id, + isActive: true, + isArchived: false, + }, + select: { id: true, mobile: true }, + }); + + if (!customer) { + // Not 401: the expert's own session is perfectly valid, it is this + // particular impersonation that is refused — so the app can clear the + // act-as state instead of logging them out. + throw new ForbiddenException('دسترسی به این مشتری مجاز نیست'); + } + + return { + id: customer.id, + mobile: customer.mobile, + type: EXPERT_TOKEN_TYPE, + adminUserId: admin.id, + actingAsCustomerId: customer.id, + }; + } + const trading = admin.salesExpertProfile?.tradingCustomer; if (!trading || !trading.isActive || trading.isArchived) { // The account is created at sign-in; if it is missing or has been diff --git a/apps/api/src/modules/orders/orders.controller.ts b/apps/api/src/modules/orders/orders.controller.ts index b2eefc9..f007ce8 100644 --- a/apps/api/src/modules/orders/orders.controller.ts +++ b/apps/api/src/modules/orders/orders.controller.ts @@ -22,11 +22,23 @@ import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strate export class OrdersController { constructor(private readonly orders: OrdersService) {} - /** The customer comes from the token; the body's `customerId` is ignored. */ + /** + * The customer comes from the token; the body's `customerId` is ignored. + * + * `me.id` is already the right customer when a sales expert is acting as one + * of theirs, so such an order lands on that customer's account and ledger with + * server-side pricing (MON-08) exactly like one they placed themselves — which + * is what the reference did by lending the expert the customer's token. + * `actingAsExpertId` records who actually placed it, which the reference could + * not do. + */ @CustomerAuth() @Post() create(@Body() dto: CreateOrderDto, @CurrentCustomer() me: AuthenticatedCustomer) { - return this.orders.createOrder(dto, me.id, { placedBy: 'customer' }); + return this.orders.createOrder(dto, me.id, { + placedBy: 'customer', + actingAsExpertId: me.actingAsCustomerId ? me.adminUserId : undefined, + }); } /** diff --git a/apps/api/src/modules/orders/orders.service.spec.ts b/apps/api/src/modules/orders/orders.service.spec.ts index 4063c62..37574c9 100644 --- a/apps/api/src/modules/orders/orders.service.spec.ts +++ b/apps/api/src/modules/orders/orders.service.spec.ts @@ -154,6 +154,39 @@ describe('OrdersService.createOrder — server-side making charge', () => { } }); + /** + * «ورود به حساب مشتری»: the order is the customer's in every respect — their + * account, their pricing — and the expert is recorded only in the log, which + * is the audit trail the reference lost by lending out the customer's token. + */ + it('records which expert placed an order on a customer’s behalf', async () => { + const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + try { + const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never; + const { service, created } = build(); + await service.createOrder(honest, 'c1', { placedBy: 'customer', actingAsExpertId: 'adm_9' }); + // Still the customer's order, priced by the server. + expect(created[0].customerId).toBe('c1'); + expect(created[0].totalGoldWeightGrams.toString()).toBe('11'); + const lines = log.mock.calls.map((c) => String(c[0])).join('\n'); + expect(lines).toContain('sales expert adm_9 acting as them'); + } finally { + log.mockRestore(); + } + }); + + it('logs no expert for an ordinary customer order', async () => { + const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + try { + const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never; + await build().service.createOrder(honest, 'c1', { placedBy: 'customer' }); + const lines = log.mock.calls.map((c) => String(c[0])).join('\n'); + expect(lines).not.toContain('acting as'); + } finally { + log.mockRestore(); + } + }); + it('stays quiet when the app sent the right figures', async () => { const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); try { diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts index 8b1d196..3aa4699 100644 --- a/apps/api/src/modules/orders/orders.service.ts +++ b/apps/api/src/modules/orders/orders.service.ts @@ -45,7 +45,16 @@ export class OrdersService { async createOrder( dto: CreateOrderDto, customerId: string, - opts: { placedBy: 'customer' | 'operator' } = { placedBy: 'operator' }, + opts: { + placedBy: 'customer' | 'operator'; + /** + * Set when a sales expert placed this through «ورود به حساب مشتری». The + * order is the customer's in every other respect; this only records who + * actually pressed the button, which the reference lost entirely by + * lending out the customer's own token. + */ + actingAsExpertId?: string; + } = { placedBy: 'operator' }, ) { const customer = await this.prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) throw new NotFoundException(`Customer ${customerId} not found`); @@ -169,6 +178,15 @@ export class OrdersService { // Legacy sent these after the order was committed // (ApiMobileController.php:733-766), so a rolled-back order never texts // anyone. Fire-and-forget per D20. + // The audit trail for «ورود به حساب مشتری». Deliberately logged after the + // commit, so it only ever claims an order that exists. + if (opts.actingAsExpertId) { + this.logger.log( + `Order ${placed.orderNumber} for customer ${customerId} was placed by ` + + `sales expert ${opts.actingAsExpertId} acting as them.`, + ); + } + this.notifyOrderPlaced(placed.orderNumber, customer, totalGoldWeightGrams, isSuspendedOverCreditLimit); return placed; diff --git a/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts b/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts index 4c45bf6..9fdddd3 100644 --- a/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts +++ b/apps/api/src/modules/sales-expert-app/sales-expert-app.controller.ts @@ -57,10 +57,18 @@ export class SalesExpertAppController { * Whether the signed-in principal is a sales expert. The apps call this once * after login to decide whether to show the two expert entries; the answer * comes from the token, so a customer cannot make it say yes. + * + * `actingAsCustomerId` lets an app that was reloaded mid-impersonation render + * its "viewing as …" bar from the server's view of the session rather than + * trusting its own stored state. */ @CustomerAuth() @Get('whoami') whoami(@CurrentCustomer() me: AuthenticatedCustomer) { - return { isSalesExpert: Boolean(me.adminUserId), type: me.type }; + return { + isSalesExpert: Boolean(me.adminUserId), + type: me.type, + actingAsCustomerId: me.actingAsCustomerId ?? null, + }; } } diff --git a/apps/user-app-v2/src/App.vue b/apps/user-app-v2/src/App.vue index 643cb8a..9d5e68f 100644 --- a/apps/user-app-v2/src/App.vue +++ b/apps/user-app-v2/src/App.vue @@ -6,7 +6,8 @@ already commented out in legacy's own App.vue, so neither ran there either. -->