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

282 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 上课程索引偶发长时间无搜索框;summary 页 Cell 更稳定。
*/
async function waitForCourseRealmVisible(page: Page) {
const deadline = Date.now() + 95_000;
while (Date.now() < deadline) {
if (
await page.getByRole('link', { name: '课程体系' }).isVisible().catch(() => false)
) {
return;
}
if (
await page
.locator('.van-cell')
.filter({ hasText: /^课程体系$/ })
.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。
* 深链到 `/course` 会触发守卫;多机构且无上下文时会先进入 `/org/select`,需点选机构后再继续。
*/
async function resolveOrgContextForCoursePage(page: Page) {
for (let attempt = 0; attempt < 6; attempt++) {
await page.goto('/course/summary', {
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;
}
if (
page.url().includes('/course/summary') ||
page.url().includes('/course')
) {
await page.waitForLoadState('networkidle', { timeout: 60_000 }).catch(() => {});
await waitForCourseRealmVisible(page);
return;
}
// 守卫可能暂时送回首页「进入工作台」等,下一循环再深链
}
throw new Error(
'resolveOrgContextForCoursePage: 无法在合理步数内进入教学资源链(/course/summary 等)'
);
}
test.describe('多机构数据隔离(会话与 token', () => {
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: 'domcontentloaded',
timeout: 60_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForURL(/\/org\/select/, { timeout: 60_000 });
await page.waitForLoadState('networkidle', { timeout: 60_000 }).catch(() => {});
await expect(page.getByText(/请选择机构|选择机构/)).toBeVisible({
timeout: 60_000,
});
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);
await expect(
page.getByText('请求失败,点击重新加载')
).not.toBeVisible({ timeout: 45_000 });
});
});