mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
7d2a22c45a
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com> Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com>
129 lines
4.2 KiB
TypeScript
129 lines
4.2 KiB
TypeScript
import { expect, Locator, Page, Request } from "@playwright/test";
|
|
|
|
import {
|
|
RUNTIME_CONFIG_SCRIPT_ID,
|
|
type RuntimePublicConfig,
|
|
} from "@/lib/runtime-config.shared";
|
|
|
|
import { BasePage } from "../base-page";
|
|
|
|
export { RUNTIME_CONFIG_SCRIPT_ID };
|
|
|
|
/** Keys the runtime data island is expected to expose (the allowlist). */
|
|
export const RUNTIME_CONFIG_KEYS = [
|
|
"sentryDsn",
|
|
"sentryEnvironment",
|
|
"googleTagManagerId",
|
|
"apiBaseUrl",
|
|
"apiDocsUrl",
|
|
"posthogKey",
|
|
"posthogHost",
|
|
"reoDevClientId",
|
|
"cloudBillingEnabled",
|
|
"cloudEnabled",
|
|
"stripePublishableKey",
|
|
"stripePublishableKeyV2",
|
|
] as const satisfies ReadonlyArray<keyof RuntimePublicConfig>;
|
|
|
|
/**
|
|
* Page object for the runtime public-config data island. The island is rendered
|
|
* into `<head>` by both root layouts, so any unauthenticated route works; the
|
|
* sign-in page is used because it needs no session.
|
|
*/
|
|
export class RuntimeConfigPage extends BasePage {
|
|
readonly island: Locator;
|
|
|
|
constructor(page: Page) {
|
|
super(page);
|
|
this.island = page.locator(`script#${RUNTIME_CONFIG_SCRIPT_ID}`);
|
|
}
|
|
|
|
async goto(): Promise<void> {
|
|
await super.goto("/sign-in");
|
|
}
|
|
|
|
/** Parsed island JSON, or null when the island is missing/malformed. */
|
|
async readConfig(): Promise<Record<string, unknown> | null> {
|
|
return this.page.evaluate((id) => {
|
|
const el = document.getElementById(id);
|
|
if (!el?.textContent) return null;
|
|
try {
|
|
return JSON.parse(el.textContent) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}, RUNTIME_CONFIG_SCRIPT_ID);
|
|
}
|
|
|
|
/** DSN the browser Sentry client initialized with, or null if uninitialized. */
|
|
async sentryInitializedDsn(): Promise<string | null> {
|
|
return this.page.evaluate(() => {
|
|
const sentry = (window as unknown as { __SENTRY__?: unknown }).__SENTRY__;
|
|
if (!sentry || typeof sentry !== "object") return null;
|
|
const hub = sentry as {
|
|
getClient?: () => unknown;
|
|
hub?: { getClient?: () => unknown };
|
|
};
|
|
const client = (hub.getClient?.() ?? hub.hub?.getClient?.()) as
|
|
| { getOptions?: () => { dsn?: string } }
|
|
| undefined;
|
|
return client?.getOptions?.().dsn ?? null;
|
|
});
|
|
}
|
|
|
|
async verifyIslandInHead(): Promise<void> {
|
|
// type="application/json" ⇒ inert, not governed by CSP script-src.
|
|
await expect(this.island).toHaveAttribute("type", "application/json");
|
|
const parentTag = await this.island.evaluate(
|
|
(el) => el.parentElement?.tagName.toLowerCase() ?? "",
|
|
);
|
|
expect(parentTag).toBe("head");
|
|
}
|
|
|
|
/**
|
|
* The island must precede the first ordered (non-`async`) bundle
|
|
* `<script src>` — that bundle is the client entry calling
|
|
* getRuntimeConfigClient(), so the island must exist before it runs. Next.js's
|
|
* `async` chunk-preloads load out of order and don't read the config, so they
|
|
* are excluded.
|
|
*/
|
|
async verifyIslandPrecedesClientBundle(): Promise<void> {
|
|
const precedes = await this.page.evaluate((id) => {
|
|
const scripts = Array.from(document.querySelectorAll("script"));
|
|
const islandIndex = scripts.findIndex((s) => s.id === id);
|
|
if (islandIndex === -1) return false;
|
|
const firstOrderedBundleIndex = scripts.findIndex(
|
|
(s) => s.src && !s.async,
|
|
);
|
|
return (
|
|
firstOrderedBundleIndex === -1 || islandIndex < firstOrderedBundleIndex
|
|
);
|
|
}, RUNTIME_CONFIG_SCRIPT_ID);
|
|
expect(precedes).toBe(true);
|
|
}
|
|
|
|
/**
|
|
* Reload the page while recording any request whose URL contains one of the
|
|
* given host fragments. Returns the matching URLs (empty ⇒ zero egress).
|
|
*/
|
|
async thirdPartyRequestsOnReload(hostFragments: string[]): Promise<string[]> {
|
|
const hits: string[] = [];
|
|
const listener = (req: Request) => {
|
|
const url = req.url();
|
|
if (hostFragments.some((fragment) => url.includes(fragment))) {
|
|
hits.push(url);
|
|
}
|
|
};
|
|
this.page.on("request", listener);
|
|
await this.page.reload({ waitUntil: "load" });
|
|
this.page.off("request", listener);
|
|
return hits;
|
|
}
|
|
|
|
async verifyGoogleTagManagerNotRendered(): Promise<void> {
|
|
await expect(
|
|
this.page.locator('script[src*="googletagmanager.com"]'),
|
|
).toHaveCount(0);
|
|
}
|
|
}
|