ソースを参照

test(auth): verify teacher account and phone login after credential reset

彭峰 5 時間 前
親
コミット
62a2525654

+ 32 - 0
docs/operations/teacher-account-reset-2026-10-20.md

@@ -0,0 +1,32 @@
+# 老师账号登录与密码重置核验(2026-10-20)
+
+用户要求老师使用原账号或绑定手机号登录,并将现有老师账号初始密码统一重置为本次指定值。密码值、手机号明细、密码哈希和会话信息仅保存在受保护的操作记录中,不写入本仓库。
+
+## 执行范围及结果
+
+- 对生产帐套重新读取全部 832 个未删除账号,锁定 `identityType=coach` 的 168 个账号;无管理员或测试账号。
+- 旧 `legacyGroupId=3` 同时包含学生,不能作为老师重置范围。学生、门店、身份未确认及冲突账号未纳入本批次。
+- 168 个老师全部重置并回读通过,失败 0;55 个启用、113 个停用,启停状态保持原样。
+- 全部密码哈希发生变化,凭据版本恰好增加 1,旧密码回退关闭,`passwordResetRequired=false`。
+- 撤销 158 个原有登录会话;原账号、姓名、昵称、手机号和业务身份字段保持原样;其他账号的回读字段未变化。
+- 操作前保存只读备份,执行前和每批操作前检查同步租约,无有效运行租约;通过预期凭据版本保护并发改密。
+- 审计记录:`BzYNAUJEYF`。
+- 批次:`teacher-password-reset-20261020-d5b8848d-6351-4cbf-a939-34a5f7491426`。
+
+## 手机号和身份待核对事项
+
+161 个老师账号具有未与其他账号共用的手机号;另 7 个账号中,1 个手机号缺失、6 个与其他账号共用。未猜测手机号或改动其他账号的绑定,已请求用户补充正确的唯一手机号。异常账号仍保留原账号登录方式。
+
+另有 12 个名称带“陪练”的账号为 `identityType=unknown`,已向用户单独询问是否纳入;已有身份冲突的账号仍待核对,不按名称直接改动身份或凭据。
+
+## 验证与交付边界
+
+现网统一登录解析已支持老师原账号、绑定手机号及旧资料手机号,无须覆盖云函数或重发前端。此次操作只重置当前明确的老师账号,不改变以后新增老师的默认密码策略。
+
+- 新增老师账号、手机号、旧资料手机号、跨帐套隔离及重复手机号拒绝的数据库回归测试。
+- `node --test scripts/tests/account-security.test.mjs`:8 项通过。
+- 生产网关实际登录共 329 项通过:55 个启用老师的原账号登录成功、49 个启用老师的唯一手机号登录成功;225 次停用账号登录均按预期拒绝。成功登录均核验返回的老师 objectId、身份、无需强制改密标志及会话有效性,随后退出测试会话。
+- 同一账号的两种登录方式按顺序验证,避免并发登录/退出干扰会话验收;未修改线上会话策略。
+- 7 个异常手机号未宣称绑定成功。实体手机及微信 WebView 未做端到端验收。
+
+受保护证据目录:`/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-20-teacher-auth/`。包括重置前后只读快照、分批进度、重置汇总和实际登录核验;不纳入 Git。

+ 18 - 0
scripts/tests/account-security.test.mjs

@@ -1,6 +1,24 @@
 import {test} from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm';import {readFileSync} from 'node:fs';import {PGlite} from '@electric-sql/pglite';
 import {test} from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm';import {readFileSync} from 'node:fs';import {PGlite} from '@electric-sql/pglite';
 const code=readFileSync(new URL('../cloud/account-security.js',import.meta.url),'utf8');
 const code=readFileSync(new URL('../cloud/account-security.js',import.meta.url),'utf8');
 const gatewaySource=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
 const gatewaySource=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+test('老师原账号和绑定手机号指向同一老师,重复手机号拒绝且不影响原账号登录',async()=>{
+ const db=new PGlite();try{
+  await db.exec(`CREATE TABLE "_User"("objectId" text,"username" text,"company" text,"realName" text,"mobile" text,"phone" text,"legacyUserId" numeric,"legacyUserData" jsonb,"isDeleted" boolean,"identityType" text);
+   INSERT INTO "_User" VALUES
+   ('coach','teacher-account','co','老师甲','13800000001',NULL,10,'{}',false,'coach'),
+   ('legacy-coach','legacy-teacher','co','老师乙','',NULL,11,'{"Mobile":"13800000002","UserName":"原老师账号"}',false,'coach'),
+   ('other','other-tenant','other-company','老师甲','13800000001',NULL,12,'{}',false,'coach');`);
+  const c=vm.createContext({DEFAULT_COMPANY_ID:'co',Psql:{query:async(sql,args)=>(await db.query(sql,args)).rows},legacyLoginResult:()=>({}),number:v=>Number(v)||0,firstValue:(...a)=>a.find(v=>v!==undefined&&v!==null&&v!==''),fail:(status,message)=>{throw Object.assign(Error(message),{status})},Parse:{Query:class{async get(id){return{id}}}}});
+  vm.runInContext(gatewaySource.slice(gatewaySource.indexOf('async function migratedLoginTarget('),gatewaySource.indexOf('const READING_CLASSES=')),c);
+  for(const login of ['teacher-account','13800000001'])assert.equal((await c.migratedLoginTarget(login)).id,'coach');
+  for(const login of ['legacy-teacher','原老师账号','13800000002'])assert.equal((await c.migratedLoginTarget(login)).id,'legacy-coach');
+  await db.exec(`INSERT INTO "_User" VALUES('member','student-account','co','学生','13800000001',NULL,13,'{}',false,'member');`);
+  await assert.rejects(c.migratedLoginTarget('13800000001'),e=>e.status===409);
+  assert.equal((await c.migratedLoginTarget('teacher-account')).id,'coach');
+  await db.exec(`UPDATE "_User" SET "isDeleted"=true WHERE "objectId"='member'`);
+  assert.equal((await c.migratedLoginTarget('13800000001')).id,'coach');
+ }finally{await db.close();}
+});
 test('会员姓名和绑定手机号解析到同一账号,昵称不参与登录,同名要求手机号',async()=>{
 test('会员姓名和绑定手机号解析到同一账号,昵称不参与登录,同名要求手机号',async()=>{
  const db=new PGlite();try{
  const db=new PGlite();try{
   await db.exec(`CREATE TABLE "_User"("objectId" text,"username" text,"company" text,"realName" text,"mobile" text,"phone" text,"legacyUserId" numeric,"legacyUserData" jsonb,"isDeleted" boolean);INSERT INTO "_User" VALUES('u1','account-one','co','可爱同学','13800000001',NULL,1,'{"TrueName":"可爱同学","HoneyName":"可改昵称"}',false),('other','another','other-company','可爱同学','13800000009',NULL,9,'{}',false);`);
   await db.exec(`CREATE TABLE "_User"("objectId" text,"username" text,"company" text,"realName" text,"mobile" text,"phone" text,"legacyUserId" numeric,"legacyUserData" jsonb,"isDeleted" boolean);INSERT INTO "_User" VALUES('u1','account-one','co','可爱同学','13800000001',NULL,1,'{"TrueName":"可爱同学","HoneyName":"可改昵称"}',false),('other','another','other-company','可爱同学','13800000009',NULL,9,'{}',false);`);