diff --git a/app/components/GenericOAuthProviders.vue b/app/components/GenericOAuthProviders.vue index 93174e5..b30dc88 100644 --- a/app/components/GenericOAuthProviders.vue +++ b/app/components/GenericOAuthProviders.vue @@ -1,11 +1,10 @@ ', + }); + return true; + } + return false; + }); +} + +async function openGenericOAuth(page, button = "本地授权登录") { + const opened = page.waitForEvent("popup", { timeout: 2500 }); + await page.getByRole("button", { name: button, exact: true }).click(); + const popup = await opened; + await popup.waitForURL(`${backendOrigin}/oauth-provider`); + assert.equal(await popup.evaluate(() => window.opener === null), true); + return popup; +} + +test( + "generic OAuth login completes from its real popup and preserves the requested page", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + let tokenReads = 0; + await genericOAuthBoundary(page, async (route, path) => { + if (path !== "/oauth/token") return false; + tokenReads += 1; + await respond(route, { jwt: "e2e-session-token" }); + return true; + }); + await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`); + const popup = await openGenericOAuth(page); + assert.equal(new URL(page.url()).pathname, "/login"); + await popup.getByRole("button", { name: "确认本地授权" }).click(); + await page.waitForURL((url) => url.pathname === "/security/ssh-keys"); + await page.getByText("audit-01", { exact: true }).waitFor(); + await sessionIsPreserved(page, "login"); + assert.equal(popup.isClosed(), true); + assert.equal(tokenReads, 1); + return { popupClosed: true, requestedPageRestored: true, tokenReads }; + }), +); + +test( + "generic OAuth continues a verified binding and keeps the requested page", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await genericOAuthBoundary(page, async (route, path) => { + if (path === "/oauth/token") { + await respond(route, { url: `${backendOrigin}/oauth.htm` }); + return true; + } + if (path === "/oauth/client/bind") { + await respond(route, { jwt: "e2e-session-token" }); + return true; + } + return false; + }); + await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`); + const popup = await openGenericOAuth(page); + await popup.getByRole("button", { name: "确认本地授权" }).click(); + await page.waitForURL((url) => url.pathname === "/oauth/bind"); + assert.equal( + new URL(page.url()).searchParams.get("redirect"), + "/security/ssh-keys", + ); + await page.getByLabel("手机号", { exact: true }).fill("13800138000"); + await page.getByLabel("验证码", { exact: true }).fill("123456"); + await page.getByRole("checkbox").check(); + await page.getByRole("button", { name: "确认绑定", exact: true }).click(); + await page.waitForURL((url) => url.pathname === "/security/ssh-keys"); + await sessionIsPreserved(page, "login"); + return { bindingVerified: true, requestedPageRestored: true }; + }), +); + +test( + "closing a generic OAuth popup requires server confirmation and permits retry", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + let tokenReads = 0; + await genericOAuthBoundary(page, async (route, path) => { + if (path !== "/oauth/token") return false; + tokenReads += 1; + await respond(route, {}); + return true; + }); + await page.goto(`${appOrigin}/login`); + const popup = await openGenericOAuth(page); + await popup.close(); + await page.getByRole("alert").waitFor(); + assert.equal(new URL(page.url()).pathname, "/login"); + await sessionIsPreserved(page, "logout"); + assert.equal(tokenReads, 1); + const retry = await openGenericOAuth(page); + await retry.close(); + await eventually( + () => page.getByRole("button", { name: "本地授权登录" }).isEnabled(), + Boolean, + "Cancelled authorization must permit another attempt", + ); + assert.equal(tokenReads, 2); + return { noUnconfirmedLogin: true, retryAllowed: true, tokenReads }; + }), +); + +test( + "generic OAuth expires its external popup without consuming a later result", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + let tokenReads = 0; + await genericOAuthBoundary(page, async (route, path) => { + if (path !== "/oauth/token") return false; + tokenReads += 1; + await respond(route, { jwt: "e2e-session-token" }); + return true; + }); + await page.goto(`${appOrigin}/login`); + const popup = await openGenericOAuth(page); + // 只推进外部墙上时钟,保留浏览器真实轮询调度,避免混用已启动页面的虚拟计时器。 + await page.clock.setFixedTime( + await page.evaluate(() => Date.now() + 10 * 60 * 1000 + 1), + ); + await page.getByRole("alert").waitFor(); + assert.equal( + await page.getByRole("button", { name: "本地授权登录" }).isEnabled(), + true, + ); + assert.equal(popup.isClosed(), false); + await popup.getByRole("button", { name: "确认本地授权" }).click(); + await eventually( + () => popup.isClosed(), + Boolean, + "The external callback must close its own popup", + ); + await page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + await sessionIsPreserved(page, "logout"); + assert.equal(tokenReads, 0); + return { + expiredAttemptReleased: true, + lateCallbackIgnored: true, + noResultConsumed: true, + }; + }), +); + +test( + "generic OAuth cleans up a blocked popup without starting server authorization", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + let authorizations = 0; + await genericOAuthBoundary(page, async (route, path) => { + if (path === "/oauth/testprovider") authorizations += 1; + return false; + }); + await page.goto(`${appOrigin}/login`); + // 浏览器能力是外部边界:模拟弹窗策略返回 null,不替换应用内部模块。 + await page.evaluate(() => { + window.open = () => null; + }); + await page.getByRole("button", { name: "本地授权登录" }).click(); + await page.getByRole("alert").waitFor(); + assert.equal( + await page.getByRole("button", { name: "本地授权登录" }).isEnabled(), + true, + ); + assert.equal(authorizations, 0); + await sessionIsPreserved(page, "logout"); + return { blockedPopupReported: true, noAuthorizationStarted: true }; + }), +); + +test( + "generic OAuth rejects an unsafe authorization URL and closes its blank popup", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await genericOAuthBoundary(page, async (route, path) => { + if (path !== "/oauth/testprovider") return false; + await respond(route, { url: "javascript:window.name='unsafe'" }); + return true; + }); + await page.goto(`${appOrigin}/login`); + const opened = page.waitForEvent("popup"); + await page.getByRole("button", { name: "本地授权登录" }).click(); + const popup = await opened; + await page.getByRole("alert").waitFor(); + await eventually( + () => popup.isClosed(), + Boolean, + "An unsafe authorization address must close its popup", + ); + assert.equal(new URL(page.url()).pathname, "/login"); + await sessionIsPreserved(page, "logout"); + return { unsafeUrlRejected: true, popupClosed: true }; + }), +); + +test( + "generic OAuth discards a pending login result after leaving its page", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + await genericOAuthBoundary(page, async (route, path) => { + if (path !== "/oauth/token") return false; + started.resolve(); + await release.promise; + await respond(route, { jwt: "test-older-oauth-session" }); + return true; + }); + await page.goto(`${appOrigin}/login`); + const popup = await openGenericOAuth(page); + await popup.getByRole("button", { name: "确认本地授权" }).click(); + await started.promise; + await page.getByRole("link", { name: "注册账号", exact: true }).click(); + await page.waitForURL((url) => url.pathname === "/register"); + const response = page.waitForResponse((entry) => + entry.url().endsWith("/oauth/token"), + ); + release.resolve(); + await (await response).finished(); + await page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + assert.equal(new URL(page.url()).pathname, "/register"); + await sessionIsPreserved(page, "logout"); + return { unmountedResultIgnored: true, noOldNavigation: true }; + }), +); + +for (const abandon of ["close", "leave", "login", "logout"]) { + test( + `generic OAuth ignores a late authorization URL after ${abandon}`, + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + let tokenReads = 0; + await genericOAuthBoundary(page, async (route, path) => { + if (path === "/oauth/testprovider") { + started.resolve(); + await release.promise; + await respond(route, { url: `${backendOrigin}/oauth-provider` }); + return true; + } + if (path === "/oauth/token") { + tokenReads += 1; + await respond(route, { jwt: "test-older-oauth-session" }); + return true; + } + return false; + }); + await page.goto(`${appOrigin}/login`); + const opened = page.waitForEvent("popup"); + await page.getByRole("button", { name: "本地授权登录" }).click(); + const popup = await opened; + await started.promise; + if (abandon === "close") await popup.close(); + else if (abandon === "leave") + await page + .getByRole("link", { name: "注册账号", exact: true }) + .click(); + else await changeSession(page, abandon); + await eventually( + () => popup.isClosed(), + Boolean, + "Abandoned authorization must close its popup", + ); + const response = page.waitForResponse((entry) => + entry.url().endsWith("/oauth/testprovider"), + ); + release.resolve(); + await (await response).finished(); + await page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + assert.equal( + new URL(page.url()).pathname, + abandon === "leave" ? "/register" : "/login", + ); + await sessionIsPreserved( + page, + abandon === "login" ? "login" : "logout", + ); + assert.equal(tokenReads, 0); + assert.equal(await page.getByRole("alert").count(), 0); + return { abandon, lateUrlIgnored: true, popupClosed: true, tokenReads }; + }), + ); +} + +for (const transition of ["login", "logout"]) { + for (const outcome of ["jwt", "bind", "error"]) { + test( + `generic OAuth ignores a late ${outcome} after cross-tab ${transition}`, + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + const started = deferred(); + const release = deferred(); + await genericOAuthBoundary(page, async (route, path) => { + if (path !== "/oauth/token") return false; + started.resolve(); + await release.promise; + await respond( + route, + outcome === "jwt" + ? { jwt: "test-older-oauth-session" } + : { url: "/oauth/bind" }, + outcome === "error" ? 422 : 200, + outcome === "error" ? "过期授权请求失败" : "", + ); + return true; + }); + await page.goto(`${appOrigin}/login`); + const popup = await openGenericOAuth(page); + await popup.getByRole("button", { name: "确认本地授权" }).click(); + await started.promise; + await changeSession(page, transition); + const response = page.waitForResponse((entry) => + entry.url().endsWith("/oauth/token"), + ); + release.resolve(); + await (await response).finished(); + await eventually( + () => + page.getByRole("button", { name: "本地授权登录" }).isEnabled(), + Boolean, + "A stale authorization must release its login button", + ); + await page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + assert.equal(new URL(page.url()).pathname, "/login"); + assert.equal(await page.getByRole("alert").count(), 0); + await sessionIsPreserved(page, transition); + return { + transition, + outcome, + lateResultIgnored: true, + newerSessionPreserved: true, + }; + }), + ); + } +} + +for (const abandon of ["leave", "login", "logout"]) { + test( + `generic OAuth does not consume a popup closed after ${abandon}`, + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + let tokenReads = 0; + await genericOAuthBoundary(page, async (route, path) => { + if (path !== "/oauth/token") return false; + tokenReads += 1; + await respond(route, { jwt: "test-older-oauth-session" }); + return true; + }); + await page.goto(`${appOrigin}/login`); + const popup = await openGenericOAuth(page); + if (abandon === "leave") { + await page + .getByRole("link", { name: "注册账号", exact: true }) + .click(); + await page.waitForURL((url) => url.pathname === "/register"); + } else { + await changeSession(page, abandon); + await eventually( + () => + page.getByRole("button", { name: "本地授权登录" }).isEnabled(), + Boolean, + "A newer session must release the old authorization attempt", + ); + } + // 外部窗口保留 opener 隔离;完成旧回调后,已结束的发起页也不得再消费结果。 + await popup.getByRole("button", { name: "确认本地授权" }).click(); + await eventually( + () => popup.isClosed(), + Boolean, + "The old callback must close its own popup", + ); + await page.evaluate( + () => + new Promise((done) => + requestAnimationFrame(() => requestAnimationFrame(done)), + ), + ); + assert.equal( + new URL(page.url()).pathname, + abandon === "leave" ? "/register" : "/login", + ); + assert.equal(await page.getByRole("alert").count(), 0); + await sessionIsPreserved( + page, + abandon === "login" ? "login" : "logout", + ); + assert.equal(tokenReads, 0); + return { abandon, endedAttemptNotResumed: true, tokenReads }; + }), + ); +} + +for (const completed of [true, false]) { + test( + `generic OAuth verifies a ${completed ? "completed" : "cancelled"} authenticated link without consuming a login token`, + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + let linked = false; + let tokenReads = 0; + await genericOAuthBoundary(page, async (route, path) => { + if (path === "/account") { + await respond(route, { + account: { + id: 1, + username: "audit-user", + oauth: [ + { + name: "testprovider", + title: "本地授权", + link: linked ? 1 : 0, + }, + ], + }, + }); + return true; + } + if (path === "/oauth/callback/testprovider") linked = true; + if (path === "/oauth/token") { + tokenReads += 1; + await respond(route, {}); + return true; + } + return false; + }); + await login(page, "/account/connections"); + await page.getByText("未关联", { exact: true }).waitFor(); + const popup = await openGenericOAuth(page, "关联账号"); + if (completed) { + await popup.getByRole("button", { name: "确认本地授权" }).click(); + await page.getByText("已关联", { exact: true }).waitFor(); + assert.equal( + await page + .getByRole("button", { name: "取消关联", exact: true }) + .isVisible(), + true, + ); + } else { + await popup.close(); + await page.getByRole("alert").waitFor(); + await page.getByText("未关联", { exact: true }).waitFor(); + assert.equal( + await page + .getByRole("button", { name: "关联账号", exact: true }) + .isEnabled(), + true, + ); + assert.equal( + await page + .getByRole("button", { name: "取消关联", exact: true }) + .count(), + 0, + ); + } + assert.equal(tokenReads, 0); + await sessionIsPreserved(page, "login"); + return { + serverLinkVerified: true, + completed, + tokenReads, + }; + }), + ); +} + test( "OAuth callback establishes a current session normally", { timeout: 25000 },