失败投递
diff --git a/web/src/theme-init.ts b/web/src/theme-init.ts
new file mode 100644
index 0000000..011cddc
--- /dev/null
+++ b/web/src/theme-init.ts
@@ -0,0 +1,10 @@
+import { initColorMode, type ColorModeController } from "@fastrd/ui-foundation";
+
+declare global {
+ interface Window {
+ fastCASColorMode: ColorModeController;
+ }
+}
+
+// Built as a blocking classic script; console and native auth reuse this handle.
+window.fastCASColorMode = initColorMode({ storageKey: "fastcas.color-mode" });
diff --git a/web/tests/console_contract.mjs b/web/tests/console_contract.mjs
index e469ef1..213b980 100644
--- a/web/tests/console_contract.mjs
+++ b/web/tests/console_contract.mjs
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
-import { mkdir } from "node:fs/promises";
+import { mkdir, writeFile } from "node:fs/promises";
import { createHmac } from "node:crypto";
import { chromium } from "playwright-core";
@@ -20,6 +20,7 @@ const context = await browser.newContext({
});
const page = await context.newPage();
const uiErrors = [];
+const integrationChecks = [];
page.on("pageerror", (error) => uiErrors.push(error.message));
await mkdir("../.local/ui-verification", { recursive: true });
const screenshot = async (name) => {
@@ -46,6 +47,85 @@ const acceptConfirmation = async () =>
.getByRole("dialog", { name: "确认操作" })
.getByRole("button", { name: "确认并继续" })
.click();
+// Delay only the application module: inspect the actual server-delivered page
+// after the blocking bootstrap, before either React or auth controls execute.
+async function firstPaint(path, stored, system, expected, denyStorage = false) {
+ const isolated = await browser.newContext({ colorScheme: system });
+ try {
+ await isolated.addInitScript(({ stored, denyStorage }) => {
+ if (stored !== null) localStorage.setItem("fastcas.color-mode", stored);
+ if (denyStorage) {
+ Storage.prototype.getItem = Storage.prototype.setItem = () => {
+ throw new DOMException("Storage denied", "SecurityError");
+ };
+ }
+ }, { stored, denyStorage });
+ const target = await isolated.newPage();
+ const errors = [];
+ target.on("pageerror", (error) => errors.push(error.message));
+ let release;
+ const held = new Promise((resolve) => { release = resolve; });
+ await target.route(/\/assets\/(console|auth)-.*\.js$/, async (route) => {
+ await held;
+ await route.continue();
+ });
+ try {
+ await target.goto(origin + path, { waitUntil: "commit" });
+ await target.waitForFunction(() => window.fastCASColorMode, { timeout: 5000 });
+ const controller = await target.evaluateHandle(() => window.fastCASColorMode);
+ assert.equal(await target.locator("html").getAttribute("data-fastui-theme"), expected);
+ release();
+ await target.getByLabel("界面主题").waitFor();
+ assert.equal(await target.evaluate((initial) => window.fastCASColorMode === initial, controller), true,
+ "application replaced the first-paint controller");
+ if (path === "/console/") {
+ await target.getByLabel("一次性凭证").fill("unsubmitted-draft-token");
+ await target.getByLabel("姓名").fill("主题草稿");
+ await target.getByLabel("新密码", { exact: true }).fill("draft-password-123");
+ await target.getByLabel("确认新密码").fill("different-password-456");
+ await target.getByRole("button", { name: "创建 FastCAS 账号" }).click();
+ await target.getByRole("alert").getByText("两次输入的密码不一致").waitFor();
+ } else {
+ await target.locator("input[name=email]").fill("draft@example.test");
+ }
+ await target.getByLabel("界面主题").selectOption(expected === "dark" ? "light" : "dark");
+ assert.equal(await target.locator("html").getAttribute("data-fastui-theme"), expected === "dark" ? "light" : "dark");
+ assert.deepEqual(errors, []);
+ integrationChecks.push({ path, stored, system, denyStorage, firstPaint: expected, sameController: true });
+ } finally {
+ release();
+ }
+ } finally {
+ await isolated.close();
+ }
+}
+
+async function preserveSecurityDraft() {
+ await page.getByLabel("界面主题").selectOption("system");
+ const input = page.getByLabel("新 FastCAS 密码", { exact: true });
+ await input.fill("preserved-password-draft-123");
+ await input.focus();
+ const original = await input.elementHandle();
+ const assertDraft = async () => {
+ assert.equal(await input.inputValue(), "preserved-password-draft-123");
+ assert.equal(await input.evaluate((element, initial) => element === initial && element === document.activeElement, original), true,
+ "theme change remounted or blurred the security form");
+ };
+ await page.emulateMedia({ colorScheme: "dark" });
+ await page.waitForFunction(() => document.documentElement.dataset.fastuiTheme === "dark");
+ await assertDraft();
+ const other = await context.newPage();
+ try {
+ await other.goto(origin + "/console/");
+ await other.getByLabel("界面主题").selectOption("light");
+ await page.waitForFunction(() => document.documentElement.dataset.fastuiTheme === "light");
+ await assertDraft();
+ } finally {
+ await other.close();
+ }
+ await input.fill("");
+ integrationChecks.push({ securityDraft: "system-and-cross-tab", sameInput: true, focus: true });
+}
page.setDefaultTimeout(5000);
function totp(uri) {
const secret = new URL(uri).searchParams.get("secret");
@@ -78,6 +158,13 @@ function totp(uri) {
return String(number % 1000000).padStart(6, "0");
}
try {
+ for (const path of ["/console/", "/login"]) {
+ await firstPaint(path, "dark", "light", "dark");
+ await firstPaint(path, "light", "dark", "light");
+ await firstPaint(path, "invalid", "dark", "dark");
+ await firstPaint(path, null, "light", "light");
+ await firstPaint(path, null, "dark", "dark", true);
+ }
await page.goto(
`${origin}/console/?flow=invite&token=${encodeURIComponent(invite)}`,
{ waitUntil: "networkidle" },
@@ -131,6 +218,10 @@ try {
.fill("invited-reader-password-123");
await page.getByRole("button", { name: "登录", exact: true }).click();
await page.getByRole("heading", { name: "你好,Invited Reader" }).waitFor();
+ assert.equal(await page.getByRole("button", { name: "应用管理", exact: true }).count(), 0);
+ assert.equal(await page.getByRole("button", { name: "调用策略", exact: true }).count(), 0);
+ assert.equal(await page.getByRole("button", { name: "失败投递", exact: true }).count(), 0);
+ await preserveSecurityDraft();
await page.getByLabel("原 FastCAS 密码").fill("invited-reader-password-123");
await page
.getByLabel("新 FastCAS 密码", { exact: true })
@@ -586,6 +677,7 @@ try {
"administrator retained management access after MFA reset",
);
assert.deepEqual(uiErrors, [], "browser JavaScript errors");
+ await writeFile("../.local/ui-verification/integration-results.json", JSON.stringify({ checks: integrationChecks, browserErrors: uiErrors, consoleContract: "passed" }, null, 2));
console.log(
"FastCAS browser contract passed: invite, login, logout, password and MFA recovery, MFA enrollment and recovery-code rotation, audit filtering and pagination, FastUI light/dark desktop themes, accessible application configuration dialogs, cancelled rotation, one-time secret isolation, delivery history, developer integration guide, application registration, service lifecycle, secret rotation and invitation",
);
diff --git a/web/tests/fasttask_contract.mjs b/web/tests/fasttask_contract.mjs
index ac0e76e..271481e 100644
--- a/web/tests/fasttask_contract.mjs
+++ b/web/tests/fasttask_contract.mjs
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import {chromium} from 'playwright-core';
+import {mkdir,writeFile} from 'node:fs/promises';
+import {assertTaskStorage,captureTaskStorage,verifyTaskStorageBoundary} from './fasttask_storage_contract.mjs';
const {FASTCAS_CONTRACT_TASK_ORIGIN: origin, FASTCAS_CONTRACT_ISSUER: issuer} = process.env;
if(!origin||!issuer)throw Error('FastTask browser contract configuration missing');
@@ -55,16 +57,10 @@ try{
assert.equal(cas.role,local.role,'FastCAS login changed project role');
await openAccount(login);
await login.getByText('已完成 FastCAS 认证').waitFor();
- const storage=await login.evaluate(()=>({
- keys:Object.keys(localStorage).sort(),
- offlineUser:localStorage.getItem('fasttask_offline_user'),
- sessionKeys:Object.keys(sessionStorage),
- }));
- assert.deepEqual(storage.keys,['fasttask_offline_user','fasttask_refresh'],'unexpected browser storage');
- assert.deepEqual(storage.sessionKeys,[],'unexpected session storage');
- const offlineUser=JSON.parse(storage.offlineUser);
- assert.deepEqual(Object.keys(offlineUser).sort(),['$schema','created_at','display_name','id','identifier','locale','revision','role','status','timezone','updated_at'],'offline user cache contains unexpected fields');
- assert.deepEqual(offlineUser,cas,'offline user cache differs from project profile');
+ await login.getByRole('separator',{name:'调整研究助手宽度'}).press('End');
+ assert.equal((await captureTaskStorage(login)).local['fasttask.research-assistant.width'],'800','assistant End did not persist its maximum width');
+ assertTaskStorage(await captureTaskStorage(login),cas);
+ const storageCases=await verifyTaskStorageBoundary(login,cas);
await fillMDUI(page,'确认本地 FastTask 密码','password-for-tests');
await page.getByText('解除 FastCAS 认证',{exact:true}).click();
await page.getByText('当前账号未认证').waitFor();
@@ -73,8 +69,12 @@ try{
const restoredMe=meResponse(page);
await page.reload({waitUntil:'networkidle'});
assert.equal((await(await restoredMe).json()).id,local.id,'local session did not survive unlink');
+ await mkdir(new URL('../../.local/fasttask-contract/',import.meta.url),{recursive:true});
+ await writeFile(new URL('../../.local/fasttask-contract/results.json',import.meta.url),JSON.stringify({
+ storageCases,profileExact:true,unlinkCompleted:true,casSessionRevoked:true,localSessionPreserved:true,stableUserAndRole:true,
+ },null,2));
}finally{await second.close()}
- console.log('FastTask browser contract passed: local administrator, explicit link, FastCAS login, revoke isolation, stable user and role');
+ console.log('FastTask browser contract passed: local administrator, explicit link, FastCAS login, exact storage/profile and rejection cases, browser unlink, CAS session revoked, local session preserved, stable user and role');
}catch(error){
console.error('browser state:',page.url(),(await page.locator('body').innerText().catch(()=>'' )).slice(0,700));
throw error;
diff --git a/web/tests/fasttask_storage_contract.mjs b/web/tests/fasttask_storage_contract.mjs
new file mode 100644
index 0000000..7ab6a12
--- /dev/null
+++ b/web/tests/fasttask_storage_contract.mjs
@@ -0,0 +1,78 @@
+import assert from 'node:assert/strict';
+
+const openKey='fasttask.research-assistant.open';
+const widthKey='fasttask.research-assistant.width';
+const profileKey='fasttask_offline_user';
+const profileFields=['$schema','created_at','display_name','id','identifier','locale','revision','role','status','timezone','updated_at'];
+
+export async function captureTaskStorage(page){
+ return page.evaluate(()=>({local:Object.fromEntries(Object.entries(localStorage)),session:Object.fromEntries(Object.entries(sessionStorage))}));
+}
+
+export function assertTaskStorage(storage,profile){
+ assert.deepEqual(Object.keys(storage.local).sort(),[openKey,widthKey,profileKey,'fasttask_refresh'],'unexpected browser storage');
+ assert.deepEqual(Object.keys(storage.session),[],'unexpected session storage');
+ // Task App writes String(boolean). ResearchWorkspace rounds and clamps width
+ // to 320..800, then writes String(number). Its displayed width may be smaller
+ // on a narrow workspace; End still persists 800. Read coercion is not a
+ // licence to retain noncanonical strings or credentials in layout preferences.
+ assert.ok(['true','false'].includes(storage.local[openKey]),'invalid assistant open preference');
+ const width=storage.local[widthKey];
+ assert.ok(typeof width==='string'&&/^[1-9]\d*$/.test(width)&&Number.isInteger(Number(width))&&Number(width)>=320&&Number(width)<=800,'invalid assistant width preference');
+ const offlineUser=JSON.parse(storage.local[profileKey]);
+ assert.deepEqual(Object.keys(offlineUser).sort(),profileFields,'offline user cache contains unexpected fields');
+ assert.deepEqual(offlineUser,profile,'offline user cache differs from project profile');
+}
+
+// Exercise the exact checker used by the live flow against actual browser
+// Storage. Only synthetic values are injected; restore before unlink/reload.
+export async function verifyTaskStorageBoundary(page,profile){
+ const original=await captureTaskStorage(page);
+ assertTaskStorage(original,profile);
+ const results=[];
+ const set=async(area,key,value)=>page.evaluate(({area,key,value})=>{
+ const storage=area==='local'?localStorage:sessionStorage;
+ if(value===null)storage.removeItem(key);else storage.setItem(key,value);
+ },{area,key,value});
+ const restore=()=>page.evaluate(snapshot=>{
+ localStorage.clear();sessionStorage.clear();
+ for(const [key,value] of Object.entries(snapshot.local))localStorage.setItem(key,value);
+ for(const [key,value] of Object.entries(snapshot.session))sessionStorage.setItem(key,value);
+ },original);
+ async function rejected(name,area,key,value,message){
+ try{
+ await set(area,key,value);
+ const observed=await captureTaskStorage(page);
+ assert.throws(()=>assertTaskStorage(observed,profile),message,`${name} was accepted`);
+ results.push({name,rejected:true});
+ }finally{await restore()}
+ }
+ try{
+ for(const open of ['true','false'])for(const width of ['320','400','560','561','799','800']){
+ await set('local',openKey,open);await set('local',widthKey,width);
+ assertTaskStorage(await captureTaskStorage(page),profile);
+ results.push({name:`canonical-${open}-${width}`,accepted:true});
+ }
+ await restore();
+ for(const key of ['unexpected','fasttask.research-assistant.token','fasttask.research-assistant.open.extra','access_token','id_token','fastcas_token','fasttask_access','fasttask_refresh_backup'])
+ await rejected(`unknown-local-${key}`,'local',key,'synthetic-credential',/unexpected browser storage/);
+ for(const key of [openKey,widthKey,profileKey,'fasttask_refresh'])
+ await rejected(`missing-${key}`,'local',key,null,/unexpected browser storage/);
+ for(const value of ['','TRUE','False','1','0','null',' true','false ','synthetic-secret','{"token":"synthetic-secret"}'])
+ await rejected(`invalid-open-${JSON.stringify(value)}`,'local',openKey,value,/invalid assistant open preference/);
+ for(const value of ['','319','801','-320','320.5','800.5','0320','0800','+320','3.2e2','8e2','0x140',' 320','320 ','NaN','Infinity','synthetic-secret','{"token":"synthetic-secret"}'])
+ await rejected(`invalid-width-${JSON.stringify(value)}`,'local',widthKey,value,/invalid assistant width preference/);
+ for(const key of ['unexpected','access_token','id_token','fastcas_token','fasttask_refresh',openKey,widthKey])
+ await rejected(`session-${key}`,'session',key,'synthetic-credential',/unexpected session storage/);
+ for(const key of ['access_token','id_token','refresh_token','fastcas_token','credential','assistant_open'])
+ await rejected(`profile-extra-${key}`,'local',profileKey,JSON.stringify({...profile,[key]:'synthetic-credential'}),/offline user cache contains unexpected fields/);
+ for(const key of profileFields){
+ const missing={...profile};delete missing[key];
+ await rejected(`profile-missing-${key}`,'local',profileKey,JSON.stringify(missing),/offline user cache contains unexpected fields/);
+ await rejected(`profile-changed-${key}`,'local',profileKey,JSON.stringify({...profile,[key]:'synthetic-mismatch'}),/offline user cache differs from project profile/);
+ }
+ }finally{await restore()}
+ assert.deepEqual(await captureTaskStorage(page),original,'storage boundary probes did not restore the browser');
+ assertTaskStorage(await captureTaskStorage(page),profile);
+ return results;
+}
diff --git a/web/vite.config.js b/web/vite.config.js
index 9726b1d..6e8d929 100644
--- a/web/vite.config.js
+++ b/web/vite.config.js
@@ -1,8 +1,60 @@
-import { defineConfig } from "vite";
+import { build, defineConfig } from "vite";
import { fileURLToPath } from "node:url";
+// The package bootstrap does not expose its controller. Build the installed
+// public API as a classic script so first paint and interactive controls share it.
+function themeBootstrap() {
+ let code;
+ let building;
+ let base;
+ async function compile() {
+ const result = await build({
+ configFile: false,
+ publicDir: false,
+ logLevel: "warn",
+ build: {
+ write: false,
+ minify: true,
+ lib: {
+ entry: fileURLToPath(new URL("./src/theme-init.ts", import.meta.url)),
+ formats: ["iife"],
+ name: "FastCASTheme",
+ },
+ },
+ });
+ code = result[0].output[0].code;
+ }
+ return {
+ name: "fastcas-theme-bootstrap",
+ configResolved(config) {
+ building = config.command === "build";
+ base = config.base;
+ },
+ transformIndexHtml: {
+ order: "post",
+ handler() {
+ return [{ tag: "script", attrs: { src: `${base}assets/theme-init.js` }, injectTo: "head-prepend" }];
+ },
+ },
+ async buildStart() {
+ if (!building) return;
+ await compile();
+ this.emitFile({ type: "asset", fileName: "assets/theme-init.js", source: code });
+ },
+ async configureServer(server) {
+ await compile();
+ server.middlewares.use((request, response, next) => {
+ if (request.url !== `${base}assets/theme-init.js`) return next();
+ response.setHeader("Content-Type", "text/javascript");
+ response.end(code);
+ });
+ },
+ };
+}
+
export default defineConfig({
base: "/console/",
+ plugins: [themeBootstrap()],
build: {
manifest: true,
rollupOptions: {