feat: add popup protected api dev test
This commit is contained in:
parent
cbcc06380d
commit
668aec45c5
155
src/popup/index.ts
Normal file
155
src/popup/index.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import {
|
||||||
|
renderDevPanel,
|
||||||
|
renderLoggedIn,
|
||||||
|
renderLoggedOut,
|
||||||
|
setProtectedApiResult
|
||||||
|
} from "./view";
|
||||||
|
import { readAuthConfig, type AuthConfig } from "../shared/auth-config";
|
||||||
|
import {
|
||||||
|
isAuthResponseMessage,
|
||||||
|
type AuthResponseMessage
|
||||||
|
} from "../shared/auth-messages";
|
||||||
|
import { createProtectedApiClient } from "../shared/protected-api-client";
|
||||||
|
|
||||||
|
interface BootPopupOptions {
|
||||||
|
config?: Partial<AuthConfig>;
|
||||||
|
document?: Document;
|
||||||
|
fetchProtectedApi?: () => Promise<unknown>;
|
||||||
|
sendMessage?: (message: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
|
||||||
|
const currentDocument = options.document ?? document;
|
||||||
|
const popupConfig = readAuthConfig(options.config);
|
||||||
|
const root = currentDocument.querySelector("#app");
|
||||||
|
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
||||||
|
|
||||||
|
if (!root || (HTMLElementCtor && !(root instanceof HTMLElementCtor))) {
|
||||||
|
throw new Error("popup root #app is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage =
|
||||||
|
options.sendMessage ??
|
||||||
|
((message: unknown) =>
|
||||||
|
Promise.resolve(
|
||||||
|
(
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
chrome?: {
|
||||||
|
runtime?: {
|
||||||
|
sendMessage?: (payload: unknown) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).chrome?.runtime?.sendMessage?.(message)
|
||||||
|
));
|
||||||
|
const fetchProtectedApi =
|
||||||
|
options.fetchProtectedApi ??
|
||||||
|
createProtectedApiClient({
|
||||||
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
|
sendMessage
|
||||||
|
}).loadProtectedMockData;
|
||||||
|
|
||||||
|
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCurrentAuthState(
|
||||||
|
root: HTMLElement,
|
||||||
|
popupConfig: AuthConfig,
|
||||||
|
sendMessage: (message: unknown) => Promise<unknown>,
|
||||||
|
fetchProtectedApi: () => Promise<unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await sendMessage({ type: "auth:get-state" });
|
||||||
|
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
||||||
|
renderLoggedOut(root, "认证状态读取失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.value.isAuthenticated) {
|
||||||
|
renderLoggedOut(root, response.value.lastError);
|
||||||
|
root
|
||||||
|
.querySelector('[data-popup-sign-in="button"]')
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
|
actionMessage: { type: "auth:sign-in" },
|
||||||
|
fetchProtectedApi
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoggedIn(root, response.value);
|
||||||
|
root
|
||||||
|
.querySelector('[data-popup-sign-out="button"]')
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
void runAuthAction(root, popupConfig, sendMessage, {
|
||||||
|
actionMessage: { type: "auth:sign-out" },
|
||||||
|
fetchProtectedApi
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (popupConfig.enableDevAuthPanel) {
|
||||||
|
renderDevPanel(root, response.value);
|
||||||
|
root
|
||||||
|
.querySelector('[data-popup-test-protected-api="button"]')
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
void runProtectedApiProbe(root, fetchProtectedApi);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAuthAction(
|
||||||
|
root: HTMLElement,
|
||||||
|
popupConfig: AuthConfig,
|
||||||
|
sendMessage: (message: unknown) => Promise<unknown>,
|
||||||
|
options: {
|
||||||
|
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
|
||||||
|
fetchProtectedApi: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await sendMessage(options.actionMessage);
|
||||||
|
|
||||||
|
if (isActionError(response)) {
|
||||||
|
renderLoggedOut(root, response.error);
|
||||||
|
root
|
||||||
|
.querySelector('[data-popup-sign-in="button"]')
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
void runAuthAction(root, popupConfig, sendMessage, options);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderCurrentAuthState(
|
||||||
|
root,
|
||||||
|
popupConfig,
|
||||||
|
sendMessage,
|
||||||
|
options.fetchProtectedApi
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActionError(response: unknown): response is Extract<AuthResponseMessage, { ok: false }> {
|
||||||
|
return (
|
||||||
|
isAuthResponseMessage(response) &&
|
||||||
|
!response.ok &&
|
||||||
|
response.type === "auth:error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProtectedApiProbe(
|
||||||
|
root: HTMLElement,
|
||||||
|
fetchProtectedApi: () => Promise<unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
setProtectedApiResult(root, "请求中...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchProtectedApi();
|
||||||
|
setProtectedApiResult(root, JSON.stringify(result, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
setProtectedApiResult(
|
||||||
|
root,
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
void bootPopup();
|
||||||
|
}
|
||||||
60
src/popup/view.ts
Normal file
60
src/popup/view.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import type { AuthStateValue } from "../shared/auth-messages";
|
||||||
|
|
||||||
|
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
|
||||||
|
root.innerHTML = `
|
||||||
|
<section data-popup-state="logged-out">
|
||||||
|
<h1>Star Chart Search Enhancer</h1>
|
||||||
|
<p>登录后才能使用星图增强功能</p>
|
||||||
|
${error ? `<p data-popup-error="true">${error}</p>` : ""}
|
||||||
|
<button type="button" data-popup-sign-in="button">登录 Logto</button>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLoggedIn(
|
||||||
|
root: HTMLElement,
|
||||||
|
authState: AuthStateValue
|
||||||
|
): void {
|
||||||
|
const userInfo = authState.userInfo;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<section data-popup-state="logged-in">
|
||||||
|
<h1>Star Chart Search Enhancer</h1>
|
||||||
|
<p>已登录</p>
|
||||||
|
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
|
||||||
|
<p>${userInfo?.email ?? ""}</p>
|
||||||
|
<button type="button" data-popup-sign-out="button">退出登录</button>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDevPanel(
|
||||||
|
root: HTMLElement,
|
||||||
|
authState: AuthStateValue
|
||||||
|
): void {
|
||||||
|
const panel = root.ownerDocument.createElement("section");
|
||||||
|
panel.dataset.popupDevPanel = "root";
|
||||||
|
panel.innerHTML = `
|
||||||
|
<h2>dev auth panel</h2>
|
||||||
|
<p>resource: ${authState.resource ?? ""}</p>
|
||||||
|
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
|
||||||
|
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
|
||||||
|
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
|
||||||
|
<p>error: ${authState.lastError ?? ""}</p>
|
||||||
|
<button type="button" data-popup-test-protected-api="button">测试受保护接口</button>
|
||||||
|
<pre data-popup-protected-api-result="output"></pre>
|
||||||
|
`;
|
||||||
|
root.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setProtectedApiResult(root: HTMLElement, value: string): void {
|
||||||
|
const output = root.querySelector(
|
||||||
|
'[data-popup-protected-api-result="output"]'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.textContent = value;
|
||||||
|
}
|
||||||
188
tests/popup-entry.test.ts
Normal file
188
tests/popup-entry.test.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { bootPopup } from "../src/popup/index";
|
||||||
|
|
||||||
|
describe("popup-entry", () => {
|
||||||
|
let dom: JSDOM;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a sign-in button when unauthenticated", async () => {
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
document: dom.window.document,
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: { isAuthenticated: false }
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dom.window.document.querySelector("button")?.textContent).toContain(
|
||||||
|
"登录"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders the dev auth panel when enabled", async () => {
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
config: { enableDevAuthPanel: true },
|
||||||
|
document: dom.window.document,
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
accessTokenExpiresAt: 1700000000000,
|
||||||
|
isAuthenticated: true,
|
||||||
|
resource: "https://api.example.test",
|
||||||
|
scopes: ["openid", "profile"],
|
||||||
|
tokenAvailable: true,
|
||||||
|
userInfo: { email: "dev@example.com", name: "Dev" }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dom.window.document.body.textContent).toContain("resource");
|
||||||
|
expect(dom.window.document.body.textContent).toContain("token");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a protected api test button in the dev panel", async () => {
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
config: { enableDevAuthPanel: true },
|
||||||
|
document: dom.window.document,
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
tokenAvailable: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-test-protected-api="button"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking the dev button runs the protected api client and prints the result", async () => {
|
||||||
|
const fetchProtectedApi = vi.fn(async () => ({
|
||||||
|
message: "authorized",
|
||||||
|
ok: true,
|
||||||
|
source: "mock-protected-api"
|
||||||
|
}));
|
||||||
|
const sendMessage = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
tokenAvailable: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
config: { enableDevAuthPanel: true },
|
||||||
|
document: dom.window.document,
|
||||||
|
fetchProtectedApi,
|
||||||
|
sendMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
dom.window.document.querySelector(
|
||||||
|
'[data-popup-test-protected-api="button"]'
|
||||||
|
) as HTMLButtonElement | null
|
||||||
|
)?.click();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fetchProtectedApi).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dom.window.document.body.textContent).toContain("authorized");
|
||||||
|
expect(dom.window.document.body.textContent).toContain("mock-protected-api");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking sign-out sends the auth:sign-out message", async () => {
|
||||||
|
const sendMessage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
userInfo: { email: "dev@example.com", name: "Dev" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:ack"
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
document: dom.window.document,
|
||||||
|
sendMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
dom.window.document.querySelector('[data-popup-sign-out="button"]') as
|
||||||
|
| HTMLButtonElement
|
||||||
|
| null
|
||||||
|
)?.click();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the auth error when sign-in fails", async () => {
|
||||||
|
const sendMessage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
error: "redirect_uri_mismatch",
|
||||||
|
ok: false,
|
||||||
|
type: "auth:error"
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
document: dom.window.document,
|
||||||
|
sendMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
dom.window.document.querySelector('[data-popup-sign-in="button"]') as
|
||||||
|
| HTMLButtonElement
|
||||||
|
| null
|
||||||
|
)?.click();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(dom.window.document.body.textContent).toContain(
|
||||||
|
"redirect_uri_mismatch"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user