From 4891e524e73e7570121eeb1160f21551bd72f286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E7=94=B7?= Date: Tue, 28 Apr 2026 08:09:36 +0800 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=E5=A4=9A=E6=9C=BA=E6=9E=84?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=20=E2=80=94=20JWT/CurrentOrgId=20=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E3=80=81=E6=9C=BA=E6=9E=84=E5=88=87=E6=8D=A2=E3=80=81?= =?UTF-8?q?=E8=AF=BE=E7=A8=8B=E4=BD=93=E7=B3=BB=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- README.md | 4 +- tests/org-data-isolation.spec.ts | 145 +++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 tests/org-data-isolation.spec.ts diff --git a/README.md b/README.md index 210683c..ee0363b 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,11 @@ npx playwright test | `tests/guest-onboarding.spec.ts` | 未登录:仅见「请返回微信小程序完成登录」,不出现已登录工作台 | | `tests/home-shell.spec.ts` | `#app`、伪造 unionid 不白屏、`page_path` 净化 | | `tests/logged-in-and-isolation.spec.ts` | 需 Secret:登录后非访客态;异主 unionid 剥离;多角色选身份后见「欢迎回来」(不校验姓名) | +| `tests/org-multi-tenant.spec.ts` | 需 Secret:机构选择页可达且呈现机构列表 UI | +| `tests/org-data-isolation.spec.ts` | 需 Secret:`CurrentOrgId` 与 JWT `current_org_id` 一致;多机构时可切换机构并更新上下文;`/course` 课程体系列表不出现「请求失败」错误态 | | `tests/admin-tasks.spec.ts` | 需 `MOICEN_ADMIN_USER` / `MOICEN_ADMIN_PASSWORD`:管理端登录后打开 `/tasks` | -未配置 `MOICEN_E2E_UNIONID` 时,`logged-in-and-isolation` 内用例全部 skip。未配置管理端账号时,`admin-tasks` 内用例 skip。 +未配置 `MOICEN_E2E_UNIONID` 时,`logged-in-and-isolation`、`org-multi-tenant`、`org-data-isolation` 内用例全部 skip。未配置管理端账号时,`admin-tasks` 内用例 skip。 ## GitHub Actions diff --git a/tests/org-data-isolation.spec.ts b/tests/org-data-isolation.spec.ts new file mode 100644 index 0000000..857077c --- /dev/null +++ b/tests/org-data-isolation.spec.ts @@ -0,0 +1,145 @@ +import { test, expect, type Page } from '@playwright/test'; + +const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim(); + +function decodeJwtPayload(token: string): Record | 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; + } catch { + return null; + } +} + +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 }); +} + +test.describe('多机构数据隔离(会话与 token)', () => { + test.skip(!moicenUnionid, '需要 MOICEN_E2E_UNIONID(Secret 或 .env.e2e)'); + + test('本地 CurrentOrgId 与 JWT current_org_id 一致', async ({ page }) => { + await establishSession(page); + if ( + await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false) + ) { + test.skip(true, '当前 unionid 会话未处于可用登录态'); + } + + const storage = await page.evaluate(() => ({ + currentOrgId: window.localStorage.getItem('CurrentOrgId'), + authorization: window.localStorage.getItem('Authorization'), + })); + expect(storage.currentOrgId, '登录后应写入 CurrentOrgId').toBeTruthy(); + expect(storage.authorization, '登录后应有 Authorization').toBeTruthy(); + + const payload = decodeJwtPayload(storage.authorization!); + expect(payload, 'Authorization 应为可解析的 JWT').not.toBeNull(); + const jwtOrgId = payload!.current_org_id; + expect(jwtOrgId, 'JWT 应包含 current_org_id').toBeTruthy(); + expect(String(jwtOrgId)).toBe(storage.currentOrgId); + }); + + test('机构选择页存在多个机构时,切换后 CurrentOrgId 变化', async ({ + page, + }) => { + await establishSession(page); + if ( + await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false) + ) { + test.skip(true, '当前 unionid 会话未处于可用登录态'); + } + + await page.goto('/org/select', { + waitUntil: 'domcontentloaded', + timeout: 60_000, + }); + await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); + await expect(page.getByRole('heading', { name: '请选择机构' })).toBeVisible({ + timeout: 60_000, + }); + + const orgCells = page.locator('.main .van-cell-group .van-cell'); + const count = await orgCells.count(); + test.skip( + count < 2, + '账号仅绑定单一机构时跳过切换断言(仍可通过 JWT 对齐用例校验隔离键)' + ); + + const beforeOrg = await page.evaluate(() => + window.localStorage.getItem('CurrentOrgId') + ); + expect(beforeOrg).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 ?? ''); + expect(payloadAfter?.current_org_id).toBeTruthy(); + expect(String(payloadAfter!.current_org_id)).toBe(afterOrg); + }); + + test('课程体系页加载成功且不进入列表错误态(机构维度接口可用)', async ({ + page, + }) => { + await establishSession(page); + if ( + await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false) + ) { + test.skip(true, '当前 unionid 会话未处于可用登录态'); + } + + await page.goto('/course', { + waitUntil: 'domcontentloaded', + timeout: 90_000, + }); + await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 }); + await expect( + page.getByPlaceholder('请输入课程体系名称搜索') + ).toBeVisible({ timeout: 90_000 }); + + await expect( + page.getByText('请求失败,点击重新加载') + ).not.toBeVisible({ timeout: 45_000 }); + }); +});