import request from 'supertest'; import { Role } from '../../../src/generated/prisma/enums.js'; import { E2E_SEED_CREDENTIALS, E2E_SEED_PASSWORD, } from '../fixtures/e2e-roles.js'; import { closeE2EContext, createE2EContext, type E2EContext, } from '../helpers/e2e-context.helper.js'; import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js'; import { expectErrorEnvelope, expectSuccessEnvelope, uniquePhone, uniqueSeedValue, } from '../helpers/e2e-http.helper.js'; import { buildMiniAppMockPayload } from '../helpers/e2e-miniapp-auth.helper.js'; describe('AuthController (e2e)', () => { let ctx: E2EContext; beforeAll(async () => { ctx = await createE2EContext(); }); afterAll(async () => { await closeE2EContext(ctx); }); describe('POST /auth/system-admin', () => { it('成功:创建系统管理员账号', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/system-admin') .send({ name: uniqueSeedValue('Auth 系统管理员'), phone: uniquePhone(), password: 'Seed@1234', openId: uniqueSeedValue('auth-system-admin-openid'), systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY, }); expectSuccessEnvelope(response, 201); expect(response.body.data.role).toBe(Role.SYSTEM_ADMIN); }); it('失败:参数不合法返回 400', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/system-admin') .send({ name: 'bad-system-admin', phone: '13800009999', password: '123', systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY, }); expectErrorEnvelope(response, 400, '密码长度至少 8 位'); }); }); describe('POST /auth/login', () => { it('成功:院内账号可使用手机号密码登录', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/login') .send({ phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone, password: E2E_SEED_PASSWORD, role: Role.DOCTOR, hospitalId: ctx.fixtures.hospitalAId, }); expectSuccessEnvelope(response, 201); expect(response.body.data.accessToken).toEqual(expect.any(String)); expect(response.body.data.actor.role).toBe(Role.DOCTOR); }); it('成功:同手机号命中多个账号时先返回候选,再确认登录', async () => { const sharedPhone = uniquePhone(); const firstUser = await request(ctx.app.getHttpServer()) .post('/users') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .send({ name: uniqueSeedValue('Password 多账号医生'), phone: sharedPhone, password: 'Seed@1234', role: Role.DOCTOR, hospitalId: ctx.fixtures.hospitalAId, departmentId: ctx.fixtures.departmentA1Id, groupId: ctx.fixtures.groupA1Id, }); expectSuccessEnvelope(firstUser, 201); const secondUser = await request(ctx.app.getHttpServer()) .post('/users') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .send({ name: uniqueSeedValue('Password 多账号工程师'), phone: sharedPhone, password: 'Seed@1234', role: Role.ENGINEER, hospitalId: ctx.fixtures.hospitalAId, }); expectSuccessEnvelope(secondUser, 201); const firstStage = await request(ctx.app.getHttpServer()) .post('/auth/login') .send({ phone: sharedPhone, password: 'Seed@1234', }); expectSuccessEnvelope(firstStage, 201); expect(firstStage.body.data.needSelect).toBe(true); expect(firstStage.body.data.accounts).toHaveLength(2); const confirmResponse = await request(ctx.app.getHttpServer()) .post('/auth/login/confirm') .send({ loginTicket: firstStage.body.data.loginTicket, userId: firstUser.body.data.id, }); expectSuccessEnvelope(confirmResponse, 201); expect(confirmResponse.body.data.user.id).toBe(firstUser.body.data.id); }); it('失败:密码错误返回 401', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/login') .send({ phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone, password: 'Wrong@1234', role: Role.DOCTOR, hospitalId: ctx.fixtures.hospitalAId, }); expectErrorEnvelope(response, 401, '手机号、密码或角色不匹配'); }); }); describe('POST /auth/miniapp/b/phone-login', () => { it('成功:单院内账号手机号可直接登录', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/b/phone-login') .send(buildMiniAppMockPayload('13800001004', 'seed-doctor-a-openid')); expectSuccessEnvelope(response, 201); expect(response.body.data.accessToken).toEqual(expect.any(String)); expect(response.body.data.actor.role).toBe(Role.DOCTOR); }); it('成功:同手机号多账号时先返回候选,再确认登录', async () => { const sharedPhone = uniquePhone(); const firstUser = await request(ctx.app.getHttpServer()) .post('/users') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .send({ name: uniqueSeedValue('MiniApp 多账号医生'), phone: sharedPhone, role: Role.DOCTOR, hospitalId: ctx.fixtures.hospitalAId, departmentId: ctx.fixtures.departmentA1Id, groupId: ctx.fixtures.groupA1Id, }); expectSuccessEnvelope(firstUser, 201); const secondUser = await request(ctx.app.getHttpServer()) .post('/users') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .send({ name: uniqueSeedValue('MiniApp 多账号工程师'), phone: sharedPhone, role: Role.ENGINEER, hospitalId: ctx.fixtures.hospitalAId, }); expectSuccessEnvelope(secondUser, 201); const firstStage = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/b/phone-login') .send( buildMiniAppMockPayload(sharedPhone, uniqueSeedValue('multi-openid')), ); expectSuccessEnvelope(firstStage, 201); expect(firstStage.body.data.needSelect).toBe(true); expect(firstStage.body.data.accounts).toHaveLength(2); const confirmResponse = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/b/phone-login/confirm') .send({ loginTicket: firstStage.body.data.loginTicket, userId: firstUser.body.data.id, }); expectSuccessEnvelope(confirmResponse, 201); expect(confirmResponse.body.data.user.id).toBe(firstUser.body.data.id); }); it('成功:同一微信 openId 可切换绑定多个院内账号', async () => { const sharedPhone = uniquePhone(); const sharedOpenId = uniqueSeedValue('shared-openid'); const firstUser = await request(ctx.app.getHttpServer()) .post('/users') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .send({ name: uniqueSeedValue('MiniApp 切换账号医生'), phone: sharedPhone, role: Role.DOCTOR, hospitalId: ctx.fixtures.hospitalAId, departmentId: ctx.fixtures.departmentA1Id, groupId: ctx.fixtures.groupA1Id, }); expectSuccessEnvelope(firstUser, 201); const secondUser = await request(ctx.app.getHttpServer()) .post('/users') .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) .send({ name: uniqueSeedValue('MiniApp 切换账号工程师'), phone: sharedPhone, role: Role.ENGINEER, hospitalId: ctx.fixtures.hospitalAId, }); expectSuccessEnvelope(secondUser, 201); const firstStageForDoctor = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/b/phone-login') .send(buildMiniAppMockPayload(sharedPhone, sharedOpenId)); expectSuccessEnvelope(firstStageForDoctor, 201); const doctorConfirm = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/b/phone-login/confirm') .send({ loginTicket: firstStageForDoctor.body.data.loginTicket, userId: firstUser.body.data.id, }); expectSuccessEnvelope(doctorConfirm, 201); const firstStageForEngineer = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/b/phone-login') .send(buildMiniAppMockPayload(sharedPhone, sharedOpenId)); expectSuccessEnvelope(firstStageForEngineer, 201); const engineerConfirm = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/b/phone-login/confirm') .send({ loginTicket: firstStageForEngineer.body.data.loginTicket, userId: secondUser.body.data.id, }); expectSuccessEnvelope(engineerConfirm, 201); const users = await ctx.prisma.user.findMany({ where: { id: { in: [firstUser.body.data.id, secondUser.body.data.id], }, }, select: { id: true, openId: true, }, orderBy: { id: 'asc' }, }); expect(users).toEqual([ { id: firstUser.body.data.id, openId: sharedOpenId }, { id: secondUser.body.data.id, openId: sharedOpenId }, ]); }); it('失败:手机号未匹配院内账号返回 404', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/b/phone-login') .send( buildMiniAppMockPayload( uniquePhone(), uniqueSeedValue('no-user-openid'), ), ); expectErrorEnvelope(response, 404, '手机号未匹配到院内账号'); }); }); describe('POST /auth/miniapp/c/phone-login', () => { it('成功:手机号唯一关联患者时可创建 C 端账号并返回 token', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/c/phone-login') .send(buildMiniAppMockPayload('13800002002', 'seed-family-a2-openid')); expectSuccessEnvelope(response, 201); expect(response.body.data.accessToken).toEqual(expect.any(String)); expect(response.body.data.familyAccount.phone).toBe('13800002002'); }); it('失败:手机号未关联患者档案返回 404', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/c/phone-login') .send( buildMiniAppMockPayload( uniquePhone(), uniqueSeedValue('family-openid'), ), ); expectErrorEnvelope(response, 404, '当前手机号未关联患者档案'); }); it('失败:手机号关联多份患者档案返回 409', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/c/phone-login') .send(buildMiniAppMockPayload('13800002001', 'seed-family-a1-openid')); expectErrorEnvelope( response, 409, '当前手机号关联了多份患者档案,请联系管理员处理', ); }); }); describe('GET /auth/me', () => { it('成功:已登录用户可读取当前信息', async () => { const response = await request(ctx.app.getHttpServer()) .get('/auth/me') .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); expectSuccessEnvelope(response, 200); expect(response.body.data.role).toBe(Role.DOCTOR); }); it('失败:未登录返回 401', async () => { const response = await request(ctx.app.getHttpServer()).get('/auth/me'); expectErrorEnvelope(response, 401, '缺少 Bearer Token'); }); it('角色矩阵:6 角色都可访问,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /auth/me role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200, [Role.DIRECTOR]: 200, [Role.LEADER]: 200, [Role.DOCTOR]: 200, [Role.ENGINEER]: 200, }, sendAsRole: async (_role, token) => request(ctx.app.getHttpServer()) .get('/auth/me') .set('Authorization', `Bearer ${token}`), sendWithoutToken: async () => request(ctx.app.getHttpServer()).get('/auth/me'), }); }); }); });