test(api): user-sync e2e aligned to V1.1 initial credentials
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,8 @@
|
|||||||
// app.get(MockUserSource) 程序化注入(mock-user-source.ts 文件头约定)。
|
// app.get(MockUserSource) 程序化注入(mock-user-source.ts 文件头约定)。
|
||||||
// 覆盖(T30 语义全量,红线逐条断言):
|
// 覆盖(T30 语义全量,红线逐条断言):
|
||||||
// ① 首轮全量:3 用户 = 1 account 关联(本地直建账号建映射,不动其
|
// ① 首轮全量:3 用户 = 1 account 关联(本地直建账号建映射,不动其
|
||||||
// 凭据/角色,会话保留)+ 2 新增待激活(credentialStatus=pending +
|
// 凭据/角色,会话保留)+ 2 新增(V1.1:credentialStatus=active +
|
||||||
// 哨兵哈希 !:pending:);counts/水位/映射行;
|
// 初始密码=账号后 6 位,可立即登录);counts/水位/映射行;
|
||||||
// ② 改名幂等:版本更高 → displayName 更新;同版本重跑 → 全跳过(counts 0);
|
// ② 改名幂等:版本更高 → displayName 更新;同版本重跑 → 全跳过(counts 0);
|
||||||
// ③ 上游停用与恢复:disabled → users.sourceStatus=disabled + 撤全部会话
|
// ③ 上游停用与恢复:disabled → users.sourceStatus=disabled + 撤全部会话
|
||||||
// (令牌 401 + 正确密码登录 401)且 **localLocked 不被写入**;恢复
|
// (令牌 401 + 正确密码登录 401)且 **localLocked 不被写入**;恢复
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
// cron tick 跳过(无新批次);放行后完成;
|
// cron tick 跳过(无新批次);放行后完成;
|
||||||
// ⑩ 增量 + 周期全量对账(RECONCILE_EVERY_N_RUNS=10):低版本行的新变更
|
// ⑩ 增量 + 周期全量对账(RECONCILE_EVERY_N_RUNS=10):低版本行的新变更
|
||||||
// 对增量不可见 → 第 10 批 cursor=null 全量对账应用;缺席用户绝不停用;
|
// 对增量不可见 → 第 10 批 cursor=null 全量对账应用;缺席用户绝不停用;
|
||||||
// ⑪ §10 闭环(Task 6 联动):同步创建的 pending 用户登录 401(哨兵哈希
|
// ⑪ V1.1 初始凭据闭环:同步即可用初始密码登录 → activate 幂等 →
|
||||||
// 不 500)→ activate → 仍 401(哈希不可猜)→ reset-password → 可登录;
|
// reset-password 轮换后初始密码失效(Task 6 端点联动);
|
||||||
// ⑫ status/runs/errors API 形状 + runs 游标分页走全量 + 参数 400 + 非
|
// ⑫ status/runs/errors API 形状 + runs 游标分页走全量 + 参数 400 + 非
|
||||||
// admin 403 / 匿名 401。
|
// admin 403 / 匿名 401。
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
@@ -256,7 +256,7 @@ describe('用户定期同步 /admin/user-sync (e2e)', () => {
|
|||||||
ext('ext-2', 'sync-u2', displayName, version);
|
ext('ext-2', 'sync-u2', displayName, version);
|
||||||
const U3 = (version: number) => ext('ext-3', 'sync-u3', '赵三', version);
|
const U3 = (version: number) => ext('ext-3', 'sync-u3', '赵三', version);
|
||||||
|
|
||||||
it('① 首轮全量:1 account 关联 + 2 新增待激活;counts/水位/映射/红线(不动凭据角色会话)', async () => {
|
it('① 首轮全量:1 account 关联 + 2 新增(V1.1 直接 active);counts/水位/映射/红线(不动凭据角色会话)', async () => {
|
||||||
scenario([U1(1), U2(1), U3(1)]); // pageSize 2 → 两页(1,2 | 3),全 v1
|
scenario([U1(1), U2(1), U3(1)]); // pageSize 2 → 两页(1,2 | 3),全 v1
|
||||||
const { run } = await triggerRun();
|
const { run } = await triggerRun();
|
||||||
|
|
||||||
@@ -266,15 +266,18 @@ describe('用户定期同步 /admin/user-sync (e2e)', () => {
|
|||||||
expect(run.cursor).toBe('1'); // 水位 = 已提交最大版本
|
expect(run.cursor).toBe('1'); // 水位 = 已提交最大版本
|
||||||
expect(run.finishedAt).not.toBeNull();
|
expect(run.finishedAt).not.toBeNull();
|
||||||
|
|
||||||
// 新增:待激活 + 哨兵哈希 + staff 角色 + 源状态。
|
// 新增(V1.1 初始凭据):直接 active + 初始密码=账号后 6 位(非哨兵哈希)。
|
||||||
for (const account of ['sync-u2', 'sync-u3']) {
|
for (const account of ['sync-u2', 'sync-u3']) {
|
||||||
const user = await userByAccount(account);
|
const user = await userByAccount(account);
|
||||||
expect(user).not.toBeNull();
|
expect(user).not.toBeNull();
|
||||||
expect(user?.credentialStatus).toBe('pending');
|
expect(user?.credentialStatus).toBe('active');
|
||||||
expect(user?.role).toBe('staff');
|
expect(user?.role).toBe('staff');
|
||||||
expect(user?.sourceStatus).toBe('active');
|
expect(user?.sourceStatus).toBe('active');
|
||||||
expect(user?.passwordHash.startsWith('!:pending:')).toBe(true);
|
expect(user?.passwordHash.startsWith('!:pending:')).toBe(false);
|
||||||
}
|
}
|
||||||
|
// V1.1:同步完成即可用初始密码登录。
|
||||||
|
const initialLogin = await login('sync-u2', 'sync-u2'.slice(-6));
|
||||||
|
expect(initialLogin.status).toBe(200);
|
||||||
|
|
||||||
// 关联:本地直建账号建映射 + 应用源 displayName/sourceStatus;
|
// 关联:本地直建账号建映射 + 应用源 displayName/sourceStatus;
|
||||||
// 凭据/角色/会话原样(红线 1:绝不写 password_hash/role)。
|
// 凭据/角色/会话原样(红线 1:绝不写 password_hash/role)。
|
||||||
@@ -413,7 +416,7 @@ describe('用户定期同步 /admin/user-sync (e2e)', () => {
|
|||||||
|
|
||||||
const u4 = await userByAccount('sync-u4');
|
const u4 = await userByAccount('sync-u4');
|
||||||
expect(u4).not.toBeNull();
|
expect(u4).not.toBeNull();
|
||||||
expect(u4?.credentialStatus).toBe('pending');
|
expect(u4?.credentialStatus).toBe('active');
|
||||||
expect(u4?.displayName).toBe('钱四');
|
expect(u4?.displayName).toBe('钱四');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -600,37 +603,36 @@ describe('用户定期同步 /admin/user-sync (e2e)', () => {
|
|||||||
).toBe(0);
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('⑪ §10 闭环:pending 登录 401(哨兵哈希不 500)→ activate → 仍 401 → reset-password → 可登录', async () => {
|
it('⑪ V1.1 初始凭据闭环:同步即可登录 → activate 幂等 → reset-password 轮换初始密码失效', async () => {
|
||||||
const u2 = await userByAccount('sync-u2');
|
const u2 = await userByAccount('sync-u2');
|
||||||
expect(u2?.credentialStatus).toBe('pending');
|
expect(u2?.credentialStatus).toBe('active');
|
||||||
|
|
||||||
// 待激活 + 哨兵哈希:任何口令 401(而非 argon2 解析 500),文案同密码错误。
|
// 初始密码 = 账号后 6 位:同步完成即可登录(V1.1 取代旧的哨兵待激活闭环)。
|
||||||
const guess = await login('sync-u2', 'Guessing-123');
|
const initial = await login('sync-u2', 'sync-u2'.slice(-6));
|
||||||
expect(guess.status).toBe(401);
|
expect(initial.status).toBe(200);
|
||||||
expect(guess.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
|
expect(typeof initial.body.sessionToken).toBe('string');
|
||||||
|
|
||||||
// 激活(credentialStatus 维度):哈希仍为哨兵 → 依旧 401。
|
// activate 对 active 用户幂等(200,不重复落审计——admin-users 语义)。
|
||||||
const activate = await request(app.getHttpServer())
|
const activate = await request(app.getHttpServer())
|
||||||
.post(`/${API_PREFIX}/admin/users/${u2?.id}/activate`)
|
.post(`/${API_PREFIX}/admin/users/${u2?.id}/activate`)
|
||||||
.set('Authorization', `Bearer ${adminToken}`)
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
.send({});
|
.send({});
|
||||||
expect(activate.status).toBe(200);
|
expect(activate.status).toBe(200);
|
||||||
expect(activate.body.credentialStatus).toBe('active');
|
expect(activate.body.credentialStatus).toBe('active');
|
||||||
const still = await login('sync-u2', 'Guessing-123');
|
|
||||||
expect(still.status).toBe(401);
|
|
||||||
expect(still.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
|
|
||||||
|
|
||||||
// 初始凭据唯一出口 = 管理员 reset-password(生成)。
|
// 管理员 reset-password 轮换:新凭据可登录,初始密码同步失效。
|
||||||
const reset = await request(app.getHttpServer())
|
const reset = await request(app.getHttpServer())
|
||||||
.post(`/${API_PREFIX}/admin/users/${u2?.id}/reset-password`)
|
.post(`/${API_PREFIX}/admin/users/${u2?.id}/reset-password`)
|
||||||
.set('Authorization', `Bearer ${adminToken}`)
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
.send({});
|
.send({});
|
||||||
expect(reset.status).toBe(200);
|
expect(reset.status).toBe(200);
|
||||||
const initial = reset.body.newPassword as string;
|
const rotated = reset.body.newPassword as string;
|
||||||
expect(initial.length).toBeGreaterThanOrEqual(12);
|
expect(rotated.length).toBeGreaterThanOrEqual(12);
|
||||||
const ok = await login('sync-u2', initial);
|
const ok = await login('sync-u2', rotated);
|
||||||
expect(ok.status).toBe(200);
|
expect(ok.status).toBe(200);
|
||||||
expect(typeof ok.body.sessionToken).toBe('string');
|
const old = await login('sync-u2', 'sync-u2'.slice(-6));
|
||||||
|
expect(old.status).toBe(401);
|
||||||
|
expect(old.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('⑫ status/runs/errors API 形状 + runs 游标分页走全量 + 参数 400 + 权限矩阵', async () => {
|
it('⑫ status/runs/errors API 形状 + runs 游标分页走全量 + 参数 400 + 权限矩阵', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user