Files
huike-e2e-moicen/tests/org-data-isolation.spec.ts
T

321 lines
11 KiB
TypeScript
Raw Normal View History

import type { Page } from '@playwright/test';
import { expect, test } from './fixtures';
const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim();
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const raw = token.startsWith('Bearer ') ? token.slice(7).trim() : token;
const parts = raw.split('.');
if (parts.length !== 3) return null;
try {
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
const json = Buffer.from(padded, 'base64').toString('utf8');
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
}
/** 与 `huike-front/src/main.ts` 中 `parseCurrentOrgIdFromToken` 一致:claims 可能在顶层或嵌在 `sub` JSON 内 */
function extractOrgIdFromTokenPayload(
payload: Record<string, unknown> | null
): string | undefined {
if (!payload) return undefined;
const top = payload.current_org_id;
if (typeof top === 'string' && top.length > 0) return top;
const sub = payload.sub;
if (typeof sub !== 'string' || sub.length === 0) return undefined;
try {
const inner = JSON.parse(sub) as Record<string, unknown>;
const innerOrg = inner.current_org_id;
if (typeof innerOrg === 'string' && innerOrg.length > 0) return innerOrg;
} catch {
return undefined;
}
return undefined;
}
/**
* 教学资源相关任意壳层可见即可(`/course/summary` 入口链或课程体系列表)。
* CI 上 SPA 长连接会导致 networkidle 不可靠;此处只用 DOM 信号轮询。
*/
async function waitForCourseRealmVisible(page: Page) {
const deadline = Date.now() + 95_000;
while (Date.now() < deadline) {
const titleHit = await page.title().catch(() => '');
if (/教学资源库|课程体系/.test(titleHit)) {
return;
}
if (
await page.getByRole('link', { name: '课程体系' }).isVisible().catch(() => false)
) {
return;
}
for (const label of ['课程体系', '课程', '课节']) {
if (
await page
.locator('.van-cell')
.filter({ hasText: new RegExp(label) })
.first()
.isVisible()
.catch(() => false)
) {
return;
}
}
if (
await page
.getByPlaceholder('请输入课程体系名称搜索')
.isVisible()
.catch(() => false)
) {
return;
}
if (
await page.locator('.van-search').first().isVisible().catch(() => false)
) {
return;
}
await page.waitForTimeout(400);
}
throw new Error(`教学资源壳层未出现:${page.url()}`);
}
async function establishSession(page: Page) {
const q = new URLSearchParams({
unionid: moicenUnionid!,
status: '2',
});
await page.goto(`/?${q.toString()}`, {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
if (
await page.getByText('请选择您的登录身份').isVisible().catch(() => false)
) {
await page.locator('.van-grid-item').first().click();
}
await expect(
page.getByText(/请选择您的登录身份|欢迎回来|进入工作台/)
).toBeVisible({ timeout: 120_000 });
}
/**
* 停留在 `/` 时 main.ts 不会把 JWT 中的机构写入 CurrentOrgId。
* 深链会触发守卫;多机构且无上下文时会先进入 `/org/select`,需点选机构后再继续。
* 交替尝试 summary 与课程索引:部分账号在 CI 上只对其中之一稳定渲染壳层。
*/
async function resolveOrgContextForCoursePage(page: Page) {
const deepPaths = ['/course/summary', '/course'] as const;
for (let attempt = 0; attempt < 8; attempt++) {
const targetPath = deepPaths[attempt % deepPaths.length];
await page.goto(targetPath, {
waitUntil: 'domcontentloaded',
timeout: 90_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
if (page.url().includes('/org/select')) {
await expect(page.getByText(/请选择机构|选择机构/)).toBeVisible({
timeout: 60_000,
});
await Promise.race([
page
.locator('.main .van-cell-group .van-cell')
.first()
.waitFor({ state: 'visible', timeout: 60_000 }),
page.getByText('暂无可用机构').waitFor({ state: 'visible', timeout: 60_000 }),
]);
const emptyOrgs = await page
.getByText('暂无可用机构')
.isVisible()
.catch(() => false);
if (emptyOrgs) {
test.skip(true, '账号无可用机构,跳过机构上下文用例');
}
const pickCells = page.locator('.main .van-cell-group .van-cell');
const n = await pickCells.count();
expect(n, '机构选择页应有可选机构').toBeGreaterThan(0);
await pickCells.first().click();
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
continue;
}
try {
const path = new URL(page.url()).pathname || '';
const onCourseRealm =
path === '/course' || path.startsWith('/course/');
if (onCourseRealm) {
await waitForCourseRealmVisible(page);
return;
}
} catch {
/* URL 解析失败则继续重试 */
}
// 守卫可能暂时送回首页「进入工作台」等,下一循环再深链
}
throw new Error(
'resolveOrgContextForCoursePage: 无法在合理步数内进入教学资源链(/course/summary 等)'
);
}
test.describe('多机构数据隔离(会话与 token', () => {
test.describe.configure({ timeout: 180_000 });
test.skip(!moicenUnionid, '需要 MOICEN_E2E_UNIONIDSecret 或 .env.e2e');
test('本地 CurrentOrgId 与 JWT current_org_id 一致', async ({ page }) => {
await establishSession(page);
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
test.skip(true, '当前 unionid 会话未处于可用登录态');
}
await resolveOrgContextForCoursePage(page);
const storage = await page.evaluate(() => ({
currentOrgId: window.localStorage.getItem('CurrentOrgId'),
authorization: window.localStorage.getItem('Authorization'),
}));
expect(storage.authorization, '登录后应有 Authorization').toBeTruthy();
const payload = decodeJwtPayload(storage.authorization!);
expect(payload, 'Authorization 应为可解析的 JWT').not.toBeNull();
const jwtOrgId = extractOrgIdFromTokenPayload(payload);
expect(jwtOrgId, 'JWT(含 sub 嵌套)应能解析出 current_org_id').toBeTruthy();
if (storage.currentOrgId) {
expect(storage.currentOrgId).toBe(jwtOrgId);
}
});
test('机构选择页存在多个机构时,切换后 CurrentOrgId 变化', async ({
page,
}) => {
await establishSession(page);
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
test.skip(true, '当前 unionid 会话未处于可用登录态');
}
await resolveOrgContextForCoursePage(page);
await page.goto('/org/select', {
waitUntil: 'load',
timeout: 90_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
await page.waitForURL(/\/org\/select/, { timeout: 90_000 });
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
test.skip(true, '打开机构页时会话已退回访客态');
}
// 线上壳层可能存在结构差异;轮询任一机构页就绪信号,避免分支超时互相误杀
await expect
.poll(
async () =>
page.evaluate(() => {
const appRoot = document.querySelector('#app');
if (!appRoot) return false;
const rootText = appRoot.textContent ?? '';
const hasOrgTitle = /请选择机构/.test(rootText);
const hasEmptyHint = rootText.includes('暂无可用机构');
const hasOrgCells = !!appRoot.querySelector('.van-cell-group .van-cell');
return hasOrgTitle || hasEmptyHint || hasOrgCells;
}),
{ timeout: 90_000, intervals: [500, 1000, 2000] }
)
.toBeTruthy();
if (
await page.getByText('暂无可用机构').isVisible().catch(() => false)
) {
test.skip(true, '机构接口无数据,跳过多机构切换断言');
}
const orgCells = page.locator('#app .van-cell-group .van-cell');
const count = await orgCells.count();
test.skip(
count < 2,
'账号仅绑定单一机构时跳过切换断言(仍可通过 JWT 对齐用例校验隔离键)'
);
let beforeOrg: string | null = await page.evaluate(() =>
window.localStorage.getItem('CurrentOrgId')
);
if (!beforeOrg) {
const authSnap = await page.evaluate(() =>
window.localStorage.getItem('Authorization')
);
beforeOrg =
extractOrgIdFromTokenPayload(
decodeJwtPayload(authSnap ?? '')
) ?? null;
}
expect(beforeOrg, '切换前应能从 localStorage 或 JWT 解析当前机构').toBeTruthy();
await orgCells.nth(1).click();
await page.waitForURL(
(u) => {
try {
return new URL(u).pathname === '/' || new URL(u).pathname === '';
} catch {
return false;
}
},
{ timeout: 90_000 }
);
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
const afterOrg = await page.evaluate(() =>
window.localStorage.getItem('CurrentOrgId')
);
expect(afterOrg).toBeTruthy();
expect(afterOrg).not.toBe(beforeOrg);
const authAfter = await page.evaluate(() =>
window.localStorage.getItem('Authorization')
);
const payloadAfter = decodeJwtPayload(authAfter ?? '');
const jwtOrgAfter = extractOrgIdFromTokenPayload(payloadAfter);
expect(jwtOrgAfter, '切换后 JWT 应携带 current_org_id').toBeTruthy();
expect(jwtOrgAfter).toBe(afterOrg);
});
test('课程体系页加载成功且不进入列表错误态(机构维度接口可用)', async ({
page,
}) => {
await establishSession(page);
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
test.skip(true, '当前 unionid 会话未处于可用登录态');
}
await resolveOrgContextForCoursePage(page);
await page.goto('/course', {
waitUntil: 'domcontentloaded',
timeout: 90_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
await waitForCourseRealmVisible(page);
const listError = page.getByText('请求失败,点击重新加载');
await expect(listError).toHaveCount(0, {
timeout: 45_000,
});
});
});