Files
dashboard/e2e/helpers/api.ts
Maycon Santos 7653e3411c Merge NetBird cloud edition into the dashboard (#674)
Brings the unified dashboard into the open-source repo. Premium features
ship in the open code, gated at runtime via NETBIRD_CLOUD and
NETBIRD_LICENSED, with upgrade prompts for unlicensed self-hosted
deployments. Adds the cloud-only feature areas (billing, integrations,
MSP, traffic events, notifications) and the Playwright e2e suite.
2026-06-21 16:01:08 +02:00

436 lines
14 KiB
TypeScript

/**
* Direct API helpers for fast CRUD operations in tests.
*
* The app uses OIDC service-worker auth, so page.request doesn't carry
* the Bearer token. We extract it from the browser context and pass it
* explicitly via page.evaluate + fetch.
*/
import type { Page } from "@playwright/test";
type Group = {
id: string;
name: string;
peers_count: number;
resources_count: number;
};
/**
* Capture the auth token and API origin by intercepting a real network
* response from the management API. We listen for any /api/ response
* and extract the request's Authorization header (injected by the OIDC
* service worker at the network level).
*/
const apiContextCache = new WeakMap<Page, { token: string; origin: string }>();
async function getApiContext(
page: Page,
): Promise<{ token: string; origin: string }> {
const cached = apiContextCache.get(page);
if (cached) return cached;
// Navigate to the users page to trigger an API call we can intercept.
// The predicate runs for EVERY response the page receives and returns
// whether it's the one we want: a successful GET to the management API.
// Non-matching responses (4xx/5xx, non-GET, non-API) are skipped — the
// wait keeps going until a match or the 10s timeout. Network-level
// request failures never produce a response, so they can't match either;
// if nothing succeeds, this throws a TimeoutError.
// Set E2E_DEBUG_API=1 to log every API response the predicate considers.
const debugApi = !!process.env.E2E_DEBUG_API;
const [response] = await Promise.all([
page.waitForResponse(
(resp) => {
const req = resp.request();
if (!resp.url().includes("/api/")) return false;
const isMatch = req.method() === "GET" && resp.status() === 200;
if (debugApi) {
// eslint-disable-next-line no-console
console.log(
`[api-context] ${req.method()} ${resp.status()} ${resp.url()} ${
isMatch ? "← MATCH" : "(skipped)"
}`,
);
}
return isMatch;
},
{ timeout: 10_000 },
),
page.goto("/team/users"),
]);
const request = response.request();
const authHeader =
(await request.allHeaders())["authorization"] || "";
const token = authHeader.replace("Bearer ", "");
const url = new URL(request.url());
const origin = `${url.protocol}//${url.host}`;
if (!token) {
throw new Error("Could not capture auth token from API response");
}
const ctx = { token, origin };
apiContextCache.set(page, ctx);
return ctx;
}
async function apiGet<T>(page: Page, path: string): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.get(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
return resp.json();
}
async function apiDelete(page: Page, path: string): Promise<void> {
const { token, origin } = await getApiContext(page);
await page.request.delete(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
/** List all groups. */
export async function listGroups(page: Page): Promise<Group[]> {
return apiGet<Group[]>(page, "/groups");
}
/** Delete a group by ID. */
export async function deleteGroup(page: Page, groupId: string) {
await apiDelete(page, `/groups/${groupId}`);
}
/** Delete all groups matching a prefix. */
export async function deleteGroupsByPrefix(page: Page, prefix: string) {
const groups = await listGroups(page);
const toDelete = groups.filter((g) => g.name.startsWith(prefix));
for (const g of toDelete) {
await deleteGroup(page, g.id);
}
}
// ── Networks ────────────────────────────────────────────────────────────
type Network = {
id: string;
name: string;
};
/** List all networks. */
export async function listNetworks(page: Page): Promise<Network[]> {
return apiGet<Network[]>(page, "/networks");
}
/** Delete a network by ID. */
export async function deleteNetworkById(page: Page, networkId: string) {
await apiDelete(page, `/networks/${networkId}`);
}
/** Delete all networks matching a prefix. */
export async function deleteNetworksByPrefix(page: Page, prefix: string) {
const networks = await listNetworks(page);
const toDelete = networks.filter((n) => n.name.startsWith(prefix));
for (const n of toDelete) {
await deleteNetworkById(page, n.id);
}
}
// ── Policies ───────────────────────────────────────────────────────────
type Policy = {
id: string;
name: string;
description: string;
rules: { sources: string[]; destinations: string[] }[];
};
/** List all policies. */
export async function listPolicies(page: Page): Promise<Policy[]> {
return apiGet<Policy[]>(page, "/policies");
}
/** Delete a policy by ID. */
export async function deletePolicyById(page: Page, policyId: string) {
await apiDelete(page, `/policies/${policyId}`);
}
/** Delete all policies whose name or description contains a substring. */
export async function deletePoliciesBySubstring(page: Page, substring: string) {
const policies = await listPolicies(page);
const toDelete = policies.filter(
(p) => p.name?.includes(substring) || p.description?.includes(substring),
);
for (const p of toDelete) {
await deletePolicyById(page, p.id);
}
}
/** Delete all policies that reference a group name in sources or destinations. */
export async function deletePoliciesByGroupName(page: Page, groupName: string) {
const [policies, groups] = await Promise.all([
listPolicies(page),
listGroups(page),
]);
const groupId = groups.find((g) => g.name === groupName)?.id;
if (!groupId) return;
const toDelete = policies.filter((p) =>
p.rules.some(
(r) => r.sources?.includes(groupId) || r.destinations?.includes(groupId),
),
);
for (const p of toDelete) {
await deletePolicyById(page, p.id);
}
}
// ── Routes ─────────────────────────────────────────────────────────────
type Route = {
id: string;
network_id: string;
};
/** List all routes. */
export async function listRoutes(page: Page): Promise<Route[]> {
return apiGet<Route[]>(page, "/routes");
}
/** Delete a route by ID. */
export async function deleteRouteById(page: Page, routeId: string) {
await apiDelete(page, `/routes/${routeId}`);
}
/** Delete all routes matching a network_id prefix. */
export async function deleteRoutesByNetworkIdPrefix(page: Page, prefix: string) {
const routes = await listRoutes(page);
const toDelete = routes.filter((r) => r.network_id.startsWith(prefix));
for (const r of toDelete) {
await deleteRouteById(page, r.id);
}
}
// ── Setup Keys ─────────────────────────────────────────────────────────
type SetupKey = {
id: string;
name: string;
};
/** List all setup keys. */
export async function listSetupKeys(page: Page): Promise<SetupKey[]> {
return apiGet<SetupKey[]>(page, "/setup-keys");
}
/** Delete a setup key by ID. */
export async function deleteSetupKeyById(page: Page, keyId: string) {
await apiDelete(page, `/setup-keys/${keyId}`);
}
/** Delete all setup keys matching a name prefix. */
export async function deleteSetupKeysByPrefix(page: Page, prefix: string) {
const keys = await listSetupKeys(page);
const toDelete = keys.filter((k) => k.name.startsWith(prefix));
for (const k of toDelete) {
await deleteSetupKeyById(page, k.id);
}
}
// ── DNS Zones ──────────────────────────────────────────────────────────
type DnsZone = {
id: string;
domain: string;
};
/** List all DNS zones. */
export async function listDnsZones(page: Page): Promise<DnsZone[]> {
return apiGet<DnsZone[]>(page, "/dns/zones");
}
/** Delete a DNS zone by ID. */
export async function deleteDnsZoneById(page: Page, zoneId: string) {
await apiDelete(page, `/dns/zones/${zoneId}`);
}
/** Delete all DNS zones matching a domain prefix. */
export async function deleteDnsZonesByPrefix(page: Page, prefix: string) {
const zones = await listDnsZones(page);
const toDelete = zones.filter((z) => z.domain.startsWith(prefix));
for (const z of toDelete) {
await deleteDnsZoneById(page, z.id);
}
}
// ── Notification Channels ─────────────────────────────────────────────
type NotificationChannel = {
id: string;
type: string;
enabled: boolean;
};
/** List all notification channels. */
export async function listNotificationChannels(page: Page): Promise<NotificationChannel[]> {
return apiGet<NotificationChannel[]>(page, "/integrations/notifications/channels");
}
/** Delete a notification channel by ID. */
export async function deleteNotificationChannel(page: Page, channelId: string) {
await apiDelete(page, `/integrations/notifications/channels/${channelId}`);
}
/** Delete all notification channels. */
export async function deleteAllNotificationChannels(page: Page) {
const channels = await listNotificationChannels(page);
for (const c of channels) {
await deleteNotificationChannel(page, c.id);
}
}
/** Delete notification channels by type (e.g., "email", "slack", "webhook"). */
export async function deleteNotificationChannelsByType(page: Page, type: string) {
const channels = await listNotificationChannels(page);
const toDelete = channels.filter((c) => c.type === type);
for (const c of toDelete) {
await deleteNotificationChannel(page, c.id);
}
}
// ── Nameservers ───────────────────────────────────────────────────────
type NameserverGroup = {
id: string;
name: string;
};
/** List all nameserver groups. */
export async function listNameserverGroups(page: Page): Promise<NameserverGroup[]> {
return apiGet<NameserverGroup[]>(page, "/dns/nameservers");
}
/** Delete a nameserver group by ID. */
export async function deleteNameserverGroupById(page: Page, id: string) {
await apiDelete(page, `/dns/nameservers/${id}`);
}
/** Delete all nameserver groups matching a name prefix. */
export async function deleteNameserverGroupsByPrefix(page: Page, prefix: string) {
const groups = await listNameserverGroups(page);
const toDelete = groups.filter((g) => g.name.startsWith(prefix));
for (const g of toDelete) {
await deleteNameserverGroupById(page, g.id);
}
}
// ── Reverse Proxy Services ────────────────────────────────────────────
type ReverseProxyService = {
id: string;
name: string;
};
/** List all reverse proxy services. */
export async function listReverseProxyServices(page: Page): Promise<ReverseProxyService[]> {
return apiGet<ReverseProxyService[]>(page, "/reverse-proxies/services");
}
/** Delete a reverse proxy service by ID. */
export async function deleteReverseProxyServiceById(page: Page, serviceId: string) {
await apiDelete(page, `/reverse-proxies/services/${serviceId}`);
}
/** Delete all reverse proxy services matching a name prefix. */
export async function deleteServicesByPrefix(page: Page, prefix: string) {
const services = await listReverseProxyServices(page);
const toDelete = services.filter((s) => s.name.startsWith(prefix));
for (const s of toDelete) {
await deleteReverseProxyServiceById(page, s.id);
}
}
// ── Reverse Proxy Clusters ────────────────────────────────────────────
type ReverseProxyCluster = {
id?: string;
address: string;
online: boolean;
connected_proxies: number;
};
/** List all reverse proxy clusters. */
export async function listReverseProxyClusters(
page: Page,
): Promise<ReverseProxyCluster[]> {
return apiGet<ReverseProxyCluster[]>(page, "/reverse-proxies/clusters");
}
/**
* Poll the management API until every given cluster address is present and
* online with at least one connected proxy. The test reverse-proxy
* containers register asynchronously after `test:setup` returns, so the
* domain picker can be briefly empty; gating here keeps the reverse-proxy
* suite deterministic instead of flaking on a half-registered env.
*/
export async function waitForProxyClustersOnline(
page: Page,
addresses: string[],
timeoutMs = 120_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let last: ReverseProxyCluster[] = [];
while (Date.now() < deadline) {
// Don't silently coerce errors to "no clusters" — a failed call (token
// capture timeout, 401, network) is a different problem than an empty
// list, and hiding it makes the gate undiagnosable.
last = await listReverseProxyClusters(page).catch((err) => {
// eslint-disable-next-line no-console
console.warn(
`[clusters-gate] list call failed: ${(err as Error).message}`,
);
return [];
});
const ready = addresses.every((addr) =>
last.some(
(c) => c.address === addr && c.online && c.connected_proxies > 0,
),
);
if (ready) return;
await page.waitForTimeout(3000);
}
throw new Error(
`Proxy clusters not online after ${timeoutMs}ms. Expected ${addresses.join(
", ",
)}; got ${JSON.stringify(last.map((c) => ({ a: c.address, online: c.online, n: c.connected_proxies })))}`,
);
}
// ── Users ─────────────────────────────────────────────────────────────
type User = {
id: string;
email: string;
name: string;
role: string;
status: string;
is_current: boolean;
};
/** List all users. */
export async function listUsers(page: Page): Promise<User[]> {
return apiGet<User[]>(page, "/users");
}
/** Delete a user by ID. */
export async function deleteUserById(page: Page, userId: string) {
await apiDelete(page, `/users/${userId}`);
}
/** Delete a user by email (skip current user). */
export async function deleteUserByEmail(page: Page, email: string) {
const users = await listUsers(page);
const user = users.find((u) => u.email === email && !u.is_current);
if (user) {
await deleteUserById(page, user.id);
}
}