303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
import { expect, test } from "../../fixtures/test-base";
|
|
import type { Browser, BrowserContext, Page } from "@playwright/test";
|
|
import {
|
|
canvasHref,
|
|
enableCanvasFeature,
|
|
expectCanvasFrameFillsHost,
|
|
getCanvas,
|
|
removeCanvas,
|
|
seedTaskCanvas,
|
|
} from "./canvas-fixture";
|
|
import {
|
|
startCanvasAuthenticatedProxy,
|
|
type CanvasAuthenticatedProxy,
|
|
} from "./canvas-authenticated-proxy-fixture";
|
|
|
|
async function openAuthenticatedCanvas(
|
|
browser: Browser,
|
|
proxy: CanvasAuthenticatedProxy,
|
|
canvasId: string,
|
|
mobile = false,
|
|
): Promise<{ context: BrowserContext; page: Page }> {
|
|
const context = await browser.newContext({
|
|
baseURL: proxy.origin,
|
|
ignoreHTTPSErrors: true,
|
|
...(mobile ? { viewport: { width: 393, height: 852 }, isMobile: true, hasTouch: true } : {}),
|
|
});
|
|
await context.addInitScript(() => {
|
|
localStorage.setItem("kandev.onboarding.completed", "true");
|
|
delete (window as unknown as { __KANDEV_API_PORT?: string }).__KANDEV_API_PORT;
|
|
});
|
|
await proxy.setAuthentication(context);
|
|
const page = await context.newPage();
|
|
await page.goto(canvasHref(canvasId));
|
|
return { context, page };
|
|
}
|
|
|
|
test.describe("authenticated same-origin canvas runtime", () => {
|
|
test("loads a retained canvas through cookie authentication", async ({
|
|
browser,
|
|
testPage,
|
|
apiClient,
|
|
backend,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
|
|
let canvasId: string | undefined;
|
|
let proxy: CanvasAuthenticatedProxy | undefined;
|
|
let context: BrowserContext | undefined;
|
|
try {
|
|
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
|
|
canvasId = seeded.canvas.id;
|
|
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl, { injectRuntimeHtml: true });
|
|
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
|
|
context = opened.context;
|
|
const page = opened.page;
|
|
|
|
await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", {
|
|
timeout: 30_000,
|
|
});
|
|
await expectCanvasFrameFillsHost(page);
|
|
const frame = page.frameLocator('iframe[title="E2E Plugin Canvas"]');
|
|
await expect(frame.getByTestId("canvas-fixture-script")).toHaveText("inline-ready");
|
|
await expect(frame.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId);
|
|
await expect(frame.getByTestId("canvas-fixture-task-count")).toHaveText("1");
|
|
await expect(frame.getByTestId("canvas-fixture-sse-status")).toHaveText("connected", {
|
|
timeout: 20_000,
|
|
});
|
|
await expect.poll(() => proxy?.count("runtime-entry", true) ?? 0).toBeGreaterThan(0);
|
|
await expect.poll(() => proxy?.count("runtime-asset", true) ?? 0).toBeGreaterThan(0);
|
|
await expect.poll(() => proxy?.count("context", true) ?? 0).toBeGreaterThan(0);
|
|
await expect.poll(() => proxy?.count("data", true) ?? 0).toBeGreaterThan(0);
|
|
await expect.poll(() => proxy?.count("events", true) ?? 0).toBeGreaterThan(0);
|
|
expect(proxy?.injectedRuntimeDocuments()).toBe(0);
|
|
expect(proxy?.observations().every((observation) => observation.host === proxy?.host)).toBe(
|
|
true,
|
|
);
|
|
|
|
await frame.getByTestId("canvas-fixture-continue").click();
|
|
await expect(frame.getByTestId("canvas-fixture-message-status")).toHaveText("accepted");
|
|
await expect
|
|
.poll(async () =>
|
|
Number(await frame.getByTestId("canvas-fixture-sse-events").textContent()),
|
|
)
|
|
.toBeGreaterThan(0);
|
|
await expect.poll(() => proxy?.count("write", true) ?? 0).toBeGreaterThan(0);
|
|
} finally {
|
|
await context?.close();
|
|
await proxy?.close();
|
|
if (canvasId) await removeCanvas(apiClient, canvasId);
|
|
await releaseFeature();
|
|
}
|
|
});
|
|
|
|
test("keeps the existing recoverable failure when a proxy ignores no-transform", async ({
|
|
browser,
|
|
testPage,
|
|
apiClient,
|
|
backend,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
|
|
let canvasId: string | undefined;
|
|
let proxy: CanvasAuthenticatedProxy | undefined;
|
|
let context: BrowserContext | undefined;
|
|
try {
|
|
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
|
|
canvasId = seeded.canvas.id;
|
|
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl, {
|
|
injectRuntimeHtml: true,
|
|
stripNoTransform: true,
|
|
});
|
|
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
|
|
context = opened.context;
|
|
|
|
await expect(opened.page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
|
|
timeout: 30_000,
|
|
});
|
|
await expect(opened.page.getByTestId("web-app-frame")).toHaveCount(0);
|
|
await expect(
|
|
opened.page.getByRole("button", { name: "Try again", exact: true }),
|
|
).toBeVisible();
|
|
expect(proxy.injectedRuntimeDocuments()).toBeGreaterThan(0);
|
|
} finally {
|
|
await context?.close();
|
|
await proxy?.close();
|
|
if (canvasId) await removeCanvas(apiClient, canvasId);
|
|
await releaseFeature();
|
|
}
|
|
});
|
|
|
|
test("releases a disconnected event stream before reconnecting", async ({
|
|
browser,
|
|
testPage,
|
|
apiClient,
|
|
backend,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
|
|
let canvasId: string | undefined;
|
|
let proxy: CanvasAuthenticatedProxy | undefined;
|
|
let context: BrowserContext | undefined;
|
|
try {
|
|
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
|
|
canvasId = seeded.canvas.id;
|
|
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl);
|
|
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
|
|
context = opened.context;
|
|
await expect(opened.page.getByTestId("web-app-frame")).toHaveAttribute(
|
|
"data-frame-state",
|
|
"ready",
|
|
{ timeout: 30_000 },
|
|
);
|
|
const frame = opened.page.frameLocator('iframe[title="E2E Plugin Canvas"]');
|
|
await expect(frame.getByTestId("canvas-fixture-sse-status")).toHaveText("connected", {
|
|
timeout: 20_000,
|
|
});
|
|
await expect.poll(() => proxy?.activeUpstreamRequests() ?? 0).toBeGreaterThan(0);
|
|
|
|
await opened.page.close();
|
|
await expect
|
|
.poll(() => proxy?.activeUpstreamRequests() ?? 0, {
|
|
timeout: 10_000,
|
|
message: "The proxy kept the disconnected canvas event stream open",
|
|
})
|
|
.toBe(0);
|
|
|
|
const reconnectedPage = await context.newPage();
|
|
await reconnectedPage.goto(canvasHref(canvasId));
|
|
await expect(reconnectedPage.getByTestId("web-app-frame")).toHaveAttribute(
|
|
"data-frame-state",
|
|
"ready",
|
|
{ timeout: 30_000 },
|
|
);
|
|
const reconnectedFrame = reconnectedPage.frameLocator('iframe[title="E2E Plugin Canvas"]');
|
|
await expect(reconnectedFrame.getByTestId("canvas-fixture-sse-status")).toHaveText(
|
|
"connected",
|
|
{ timeout: 20_000 },
|
|
);
|
|
await expect.poll(() => proxy?.count("events", true) ?? 0).toBeGreaterThan(1);
|
|
} finally {
|
|
await context?.close();
|
|
await proxy?.close();
|
|
if (canvasId) await removeCanvas(apiClient, canvasId);
|
|
await releaseFeature();
|
|
}
|
|
});
|
|
|
|
test("recovers after proxy authentication expires", async ({
|
|
browser,
|
|
testPage,
|
|
apiClient,
|
|
backend,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
|
|
let canvasId: string | undefined;
|
|
let proxy: CanvasAuthenticatedProxy | undefined;
|
|
let context: BrowserContext | undefined;
|
|
try {
|
|
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
|
|
canvasId = seeded.canvas.id;
|
|
const releaseBefore = (await getCanvas(apiClient, canvasId))?.active_release_id;
|
|
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl);
|
|
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
|
|
context = opened.context;
|
|
const page = opened.page;
|
|
await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", {
|
|
timeout: 30_000,
|
|
});
|
|
|
|
await proxy.clearAuthentication(context);
|
|
await page.reload();
|
|
await expect(page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
|
|
timeout: 30_000,
|
|
});
|
|
await expect(page.getByTestId("web-app-frame")).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible();
|
|
|
|
await proxy.setAuthentication(context);
|
|
await page.getByRole("button", { name: "Try again", exact: true }).click();
|
|
await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", {
|
|
timeout: 30_000,
|
|
});
|
|
await expectCanvasFrameFillsHost(page);
|
|
expect((await getCanvas(apiClient, canvasId))?.active_release_id).toBe(releaseBefore);
|
|
await expect.poll(() => proxy?.count("runtime-entry", false) ?? 0).toBeGreaterThan(0);
|
|
await expect.poll(() => proxy?.count("runtime-entry", true) ?? 0).toBeGreaterThan(1);
|
|
} finally {
|
|
await context?.close();
|
|
await proxy?.close();
|
|
if (canvasId) await removeCanvas(apiClient, canvasId);
|
|
await releaseFeature();
|
|
}
|
|
});
|
|
|
|
test("preserves capability checks with cookies", async ({
|
|
browser,
|
|
testPage,
|
|
apiClient,
|
|
backend,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
|
|
let canvasId: string | undefined;
|
|
let proxy: CanvasAuthenticatedProxy | undefined;
|
|
let context: BrowserContext | undefined;
|
|
try {
|
|
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
|
|
canvasId = seeded.canvas.id;
|
|
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl);
|
|
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
|
|
context = opened.context;
|
|
const page = opened.page;
|
|
await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", {
|
|
timeout: 30_000,
|
|
});
|
|
|
|
const checks = await page.evaluate(async () => {
|
|
const frame = document.querySelector<HTMLIFrameElement>(
|
|
'iframe[title="E2E Plugin Canvas"]',
|
|
);
|
|
if (!frame?.src) throw new Error("canvas runtime URL was not mounted");
|
|
const runtimeURL = new URL(frame.src);
|
|
const marker = "/api/v1/plugins/web-apps/runtime/";
|
|
const tokenStart = runtimeURL.pathname.indexOf(marker) + marker.length;
|
|
const tokenEnd = runtimeURL.pathname.indexOf("/", tokenStart);
|
|
if (tokenStart < marker.length || tokenEnd < 0) {
|
|
throw new Error("canvas runtime URL did not contain a capability segment");
|
|
}
|
|
runtimeURL.pathname = `${runtimeURL.pathname.slice(0, tokenStart)}invalid-capability/${runtimeURL.pathname.slice(tokenEnd + 1)}`;
|
|
const invalidToken = await fetch(runtimeURL, { credentials: "same-origin" });
|
|
const deniedWrite = await fetch(`${frame.src}_kandev/v1/actions/not-declared`, {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: "{}",
|
|
});
|
|
return {
|
|
deniedBody: await deniedWrite.text(),
|
|
deniedStatus: deniedWrite.status,
|
|
invalidBody: await invalidToken.text(),
|
|
invalidStatus: invalidToken.status,
|
|
};
|
|
});
|
|
|
|
expect(checks.invalidStatus).toBe(404);
|
|
expect(checks.invalidBody).toContain("runtime_token_invalid");
|
|
expect(checks.deniedStatus).toBe(403);
|
|
expect(checks.deniedBody).toContain("plugin_permission_denied");
|
|
} finally {
|
|
await context?.close();
|
|
await proxy?.close();
|
|
if (canvasId) await removeCanvas(apiClient, canvasId);
|
|
await releaseFeature();
|
|
}
|
|
});
|
|
});
|