Compare commits

..
Author SHA1 Message Date
Viktor Liu a0f4a3564a Add CrowdSec AppSec mode to reverse proxy access control 2026-07-27 15:56:09 +02:00
300 changed files with 5121 additions and 48552 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"extends": ["next/core-web-vitals","prettier"],
"plugins": ["simple-import-sort"],
"rules": {
"simple-import-sort/imports": [
"warn",
{
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
}
],
"simple-import-sort/exports": "warn"
}
}
+2 -5
View File
@@ -99,14 +99,12 @@ jobs:
type=sha
- name: Log in to Docker Hub
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
with:
username: ${{ secrets.NB_DOCKER_USER }}
password: ${{ secrets.NB_DOCKER_TOKEN }}
- name: Log in to the GitHub Container registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -118,12 +116,11 @@ jobs:
with:
context: .
file: docker/Dockerfile
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
push: true
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: |
- run: |
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
-81
View File
@@ -1,81 +0,0 @@
name: Bump netbird wasm client
on:
workflow_dispatch:
inputs:
tag:
description: 'NetBird release tag (e.g. v0.78.0)'
required: true
type: string
concurrency:
group: ${{ github.workflow }}-${{ inputs.tag }}
cancel-in-progress: false
jobs:
bump:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.NETBIRD_DEV_BOT_GITHUB_TOKEN }}
- name: Configure git
run: |
git config --global user.email "dev@netbird.io"
git config --global user.name "netbirddev"
# The tag rides in env and is validated before use, so a crafted dispatch
# input cannot expand into shell source or reach the sed expression.
- name: Update the default wasm path
id: bump
env:
TAG: ${{ inputs.tag }}
run: |
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Refusing tag '$TAG': expected vMAJOR.MINOR.PATCH"
exit 1
fi
sed -i "s|https://pkgs.netbird.io/wasm/client/v[0-9.]*|https://pkgs.netbird.io/wasm/client/${TAG}|" src/utils/config.ts
branch="bump-netbird-wasm-${TAG#v}"
echo "branch=${branch}" >> "$GITHUB_OUTPUT"
# The branch name is derived from the tag, so a re-dispatch of the
# same release would collide with the branch and pull request an
# earlier run left behind.
if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Branch ${branch} already exists, ${TAG} was handled by an earlier run"
exit 0
fi
git checkout -b "${branch}"
git add src/utils/config.ts
if git diff --cached --quiet; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Default wasm path already at ${TAG}, nothing to do"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
git commit -m "Bump the WASM client to ${TAG}"
git push --set-upstream origin "${branch}"
- name: Create pull request
if: steps.bump.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.NETBIRD_DEV_BOT_GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
BRANCH: ${{ steps.bump.outputs.branch }}
run: |
gh pr create \
--base main \
--head "$BRANCH" \
--title "Bump the WASM client to ${TAG}" \
--body "Automated wasm path bump triggered by netbird release ${TAG}."
+3 -47
View File
@@ -101,61 +101,17 @@ jobs:
echo "Using reverse-proxy tag: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Restore geolocation database cache
id: geo-cache
uses: actions/cache/restore@v4
with:
path: e2e/environment/geo-cache
key: geolite-dbs-v1
- name: Build dashboard and set up test environment
- name: Setup test environment
env:
MANAGEMENT_IMAGE_TAG: ${{ steps.management_tag.outputs.tag }}
REVERSE_PROXY_IMAGE_TAG: ${{ steps.reverse_proxy_tag.outputs.tag }}
# The docker environment (image pulls, Zitadel bootstrap) is
# network-bound and the Next build is CPU-bound, so they overlap.
# The build reads .test-config.json via the @/config/test alias,
# and create-test-env.sh renders that file only after the Zitadel
# bootstrap — so the build waits for it before starting.
run: |
(cd ./e2e/environment && bash create-test-env.sh) > env-setup.log 2>&1 &
ENV_PID=$!
until [ -s .test-config.json ] && jq empty .test-config.json 2>/dev/null; do
if ! kill -0 "$ENV_PID" 2>/dev/null; then break; fi
sleep 2
done
if ! [ -s .test-config.json ]; then
echo "::error::create-test-env.sh exited before rendering .test-config.json"
cat env-setup.log
exit 1
fi
# Lowest CPU priority: the containers must win the scheduler, or
# the build can starve management past the proxy-registration wait.
if ! nice -n 19 env APP_ENV=test npx next build; then
kill "$ENV_PID" 2>/dev/null || true
exit 1
fi
if ! wait "$ENV_PID"; then
echo "::error::Test environment setup failed"
cat env-setup.log
exit 1
fi
cat env-setup.log
# Saved right after env setup rather than in a post step, so a later
# test failure doesn't lose the cache.
- name: Save geolocation database cache
if: always() && steps.geo-cache.outputs.cache-hit != 'true' && hashFiles('e2e/environment/geo-cache/**') != ''
uses: actions/cache/save@v4
with:
path: e2e/environment/geo-cache
key: geolite-dbs-v1
run: cd ./e2e/environment && bash create-test-env.sh
- name: Run Playwright tests
id: playwright
run: |
set -o pipefail
npx playwright test --config=e2e/playwright.config.ts 2>&1 | tee playwright-output.log
npm run test:ci 2>&1 | tee playwright-output.log
- name: Append Playwright summary to job summary
if: always() && hashFiles('e2e/test-results/results.json') != ''
-32
View File
@@ -1,32 +0,0 @@
name: Unit Tests
on:
push:
branches:
- main
pull_request:
# Cancel superseded runs on the same ref so a new push doesn't queue behind an
# outdated one (mirrors build_and_push.yml).
concurrency:
group: unit-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
vitest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: setup-node
uses: actions/setup-node@v5
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
-1
View File
@@ -44,7 +44,6 @@ e2e/test-results/
e2e/playwright-report/
/test-results/
/playwright-report/
playwright-report*.zip
.configs/.local-config.zitadel.json
.configs/.staging-config.json
.configs/.temp-config.json
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24
FROM alpine:3.14
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
nginx curl supervisor certbot-nginx && \
-1
View File
@@ -218,7 +218,6 @@ await visitByNavigation(page, "Policies"); // Click child
| Notifications | `settings-notifications-email.spec.ts`, `settings-notifications-slack.spec.ts`, `settings-notifications-webhook.spec.ts` | `@notifications` |
| Team | `team-users.spec.ts`, `team-service-users.spec.ts`, `team-users-approval-and-billing.spec.ts` | `@team` |
| Setup Keys | `setup-keys.spec.ts` | `@setup-keys` |
| Control Center | `control-center-*.spec.ts` (`-draft`, `-draft-connections`, `-draft-matrix`, `-draft-extras`, `-draft-install`, `-draft-remove`, `-draft-shortcuts`, `-deploy`, `-drilldown`, `-live`, `-peers`) | `@control-center` |
## Debugging
+1 -3
View File
@@ -9,6 +9,4 @@ proxy-no-ports.env
proxy-certs/
proxy-certs-no-ports/
docker-compose.yml
/machinekey
geo-cache/
geo-dump/
/machinekey
+2 -52
View File
@@ -61,13 +61,7 @@ wait_proxy_cluster() {
SERVICE_NAME=${1:-reverse-proxy}
echo -n "Waiting for $SERVICE_NAME to register with management "
set +e
# 480s: management downloads its geolocation databases on first boot
# (the proxy specs need /locations/countries, so it cannot be disabled
# there), and a slow pkgs.netbird.io can stall that well past the old
# 120s budget. The proxies skip their download entirely via
# NB_PROXY_DISABLE_GEOLOCATION. The loop exits as soon as the sync
# lands, so healthy runs pay nothing.
local attempts=240
local attempts=60
local i
for ((i = 1; i <= attempts; i++)); do
if $DOCKER_COMPOSE_COMMAND logs "$SERVICE_NAME" 2>&1 | grep -q "Initial mapping sync complete"; then
@@ -595,8 +589,6 @@ initEnvironment() {
echo -e "\nCreating proxy access tokens...\n"
init_proxy_tokens
exportGeoDatabases
echo -e "\nStarting reverse proxy services...\n"
$DOCKER_COMPOSE_COMMAND up -d reverse-proxy reverse-proxy-no-ports
@@ -768,41 +760,7 @@ ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE=$ZIDATE_TOKEN_EXPIRATION_DA
EOF
}
# Copies management's geolocation databases out of its data volume into
# ./geo-cache so CI can cache them across runs. Management has finished
# loading them by the time its API serves requests. A no-op when the
# cache already holds both files, and never fatal: a failed export just
# means the next run downloads again.
exportGeoDatabases() {
mkdir -p geo-cache
if compgen -G "geo-cache/GeoLite2-City_*.mmdb" > /dev/null \
&& compgen -G "geo-cache/geonames_*.db" > /dev/null; then
return
fi
echo -e "\nExporting geolocation databases for caching...\n"
rm -rf geo-dump
if $DOCKER_COMPOSE_COMMAND cp management:/var/lib/netbird ./geo-dump; then
cp geo-dump/GeoLite2-City_*.mmdb geo-dump/geonames_*.db geo-cache/ 2>/dev/null \
|| echo "WARN: no geolocation databases found in management data dir"
rm -rf geo-dump
else
echo "WARN: could not copy management data dir; geolocation databases not cached"
fi
}
renderDockerCompose() {
# Cached geolocation databases (restored by CI into ./geo-cache) are
# mounted into management's data dir so it skips the slow first-boot
# download from pkgs.netbird.io. With no cache the mounts are omitted
# and management downloads as before.
local geo_mounts=""
local f
for f in geo-cache/GeoLite2-City_*.mmdb geo-cache/geonames_*.db; do
[ -f "$f" ] || continue
geo_mounts+="
- ./${f}:/var/lib/netbird/$(basename "$f")"
done
cat <<EOF
version: "3.4"
services:
@@ -836,7 +794,7 @@ services:
- NB_TRAFFIC_FLOW_INTERVAL=20s
- NB_SINGLE_INSTANCE_MODE=true
volumes:
- netbird_management:/var/lib/netbird${geo_mounts}
- netbird_management:/var/lib/netbird
- ./management.json:/etc/netbird/management.json
command: [
"--port", "80",
@@ -887,11 +845,6 @@ services:
networks: [netbird]
env_file:
- ./proxy.env
environment:
# No spec exercises country-based enforcement, and skipping the
# GeoLite2 download removes a startup stall of up to 2 minutes
# when pkgs.netbird.io is slow.
- NB_PROXY_DISABLE_GEOLOCATION=true
volumes:
- ./proxy-certs:/certs:ro
command: [
@@ -913,9 +866,6 @@ services:
networks: [netbird]
env_file:
- ./proxy-no-ports.env
environment:
# See the primary proxy: no spec needs geo enforcement.
- NB_PROXY_DISABLE_GEOLOCATION=true
volumes:
# Distinct cert dir so this proxy has a distinct identity from the
# primary proxy; a shared cert makes both register under the same
+73 -128
View File
@@ -1,5 +1,10 @@
// The app uses OIDC service-worker auth, so page.request carries no Bearer
// token: it is intercepted off a real API response and passed explicitly.
/**
* 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 = {
@@ -9,6 +14,12 @@ type Group = {
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(
@@ -17,6 +28,13 @@ async function getApiContext(
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([
@@ -24,11 +42,7 @@ async function getApiContext(
(resp) => {
const req = resp.request();
if (!resp.url().includes("/api/")) return false;
// A tokenless 200 can arrive before the service worker injects the token.
const isMatch =
req.method() === "GET" &&
resp.status() === 200 &&
!!req.headers()["authorization"];
const isMatch = req.method() === "GET" && resp.status() === 200;
if (debugApi) {
// eslint-disable-next-line no-console
console.log(
@@ -39,10 +53,9 @@ async function getApiContext(
}
return isMatch;
},
// `next dev` compiles the route on demand before any /api response comes.
{ timeout: 30_000 },
{ timeout: 10_000 },
),
page.goto("/team/users", { timeout: 30_000 }),
page.goto("/team/users"),
]);
const request = response.request();
@@ -76,46 +89,17 @@ async function apiDelete(page: Page, path: string): Promise<void> {
});
}
async function apiPost<T>(page: Page, path: string, data: unknown): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.post(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
data,
});
return resp.json();
}
/** List all groups. */
export async function listGroups(page: Page): Promise<Group[]> {
return apiGet<Group[]>(page, "/groups");
}
type Peer = { id: string; name: string; hostname: string; connected: boolean };
export async function listPeers(page: Page): Promise<Peer[]> {
return apiGet<Peer[]>(page, "/peers");
}
export async function deletePeerById(page: Page, peerId: string) {
await apiDelete(page, `/peers/${peerId}`);
}
export async function deletePeersByPrefix(page: Page, prefix: string) {
const peers = await listPeers(page);
for (const p of peers) {
if (p.name?.startsWith(prefix) || p.hostname?.startsWith(prefix)) {
await deletePeerById(page, p.id);
}
}
}
export async function createGroup(page: Page, name: string): Promise<Group> {
return apiPost<Group>(page, "/groups", { name, peers: [] });
}
/** 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));
@@ -131,40 +115,17 @@ type Network = {
name: string;
};
/** List all networks. */
export async function listNetworks(page: Page): Promise<Network[]> {
return apiGet<Network[]>(page, "/networks");
}
export async function createNetwork(
page: Page,
name: string,
description = "",
): Promise<Network> {
return apiPost<Network>(page, "/networks", { name, description });
}
/** Delete a network by ID. */
export async function deleteNetworkById(page: Page, networkId: string) {
await apiDelete(page, `/networks/${networkId}`);
}
type NetworkResource = { id: string; name: string; address: string };
export async function createResource(
page: Page,
networkId: string,
name: string,
address: string,
groupIds: string[],
): Promise<NetworkResource> {
return apiPost<NetworkResource>(page, `/networks/${networkId}/resources`, {
name,
description: name,
address,
groups: groupIds,
enabled: true,
});
}
/** 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));
@@ -182,40 +143,17 @@ type Policy = {
rules: { sources: string[]; destinations: string[] }[];
};
/** List all policies. */
export async function listPolicies(page: Page): Promise<Policy[]> {
return apiGet<Policy[]>(page, "/policies");
}
// Groups need no peers, so this renders policy/group nodes without any peer.
export async function createPolicy(
page: Page,
name: string,
sourceGroupId: string,
destinationGroupId: string,
enabled = true,
): Promise<Policy> {
return apiPost<Policy>(page, "/policies", {
name,
description: name,
enabled,
rules: [
{
name,
enabled,
bidirectional: true,
protocol: "all",
action: "accept",
sources: [sourceGroupId],
destinations: [destinationGroupId],
},
],
});
}
/** 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(
@@ -226,6 +164,7 @@ export async function deletePoliciesBySubstring(page: Page, substring: string) {
}
}
/** 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),
@@ -251,14 +190,17 @@ type Route = {
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));
@@ -274,31 +216,17 @@ type SetupKey = {
name: string;
};
/** List all setup keys. */
export async function listSetupKeys(page: Page): Promise<SetupKey[]> {
return apiGet<SetupKey[]>(page, "/setup-keys");
}
/** The plaintext `key` is only present on the creation response. */
export async function createSetupKey(
page: Page,
name: string,
autoGroupIds: string[] = [],
): Promise<SetupKey & { key: string }> {
return apiPost<SetupKey & { key: string }>(page, "/setup-keys", {
name,
type: "reusable",
expires_in: 86400,
revoked: false,
auto_groups: autoGroupIds,
usage_limit: 0,
ephemeral: false,
});
}
/** 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));
@@ -314,14 +242,17 @@ type DnsZone = {
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));
@@ -338,14 +269,17 @@ type NotificationChannel = {
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) {
@@ -353,6 +287,7 @@ export async function deleteAllNotificationChannels(page: Page) {
}
}
/** 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);
@@ -368,14 +303,17 @@ type NameserverGroup = {
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));
@@ -391,14 +329,17 @@ type ReverseProxyService = {
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));
@@ -416,13 +357,20 @@ type ReverseProxyCluster = {
connected_proxies: number;
};
/** List all reverse proxy clusters. */
export async function listReverseProxyClusters(
page: Page,
): Promise<ReverseProxyCluster[]> {
return apiGet<ReverseProxyCluster[]>(page, "/reverse-proxies/clusters");
}
// The test proxy containers register asynchronously after `test:setup` returns.
/**
* 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[],
@@ -431,6 +379,9 @@ export async function waitForProxyClustersOnline(
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(
@@ -464,14 +415,17 @@ type User = {
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);
@@ -480,7 +434,9 @@ export async function deleteUserByEmail(page: Page, email: string) {
}
}
// ── Agent Network ──────────────────────────────────────────────────────
// ---------------------------------------------------------------------------
// Agent Network
// ---------------------------------------------------------------------------
type AgentNetworkCatalogProvider = {
id: string;
@@ -493,6 +449,7 @@ type AgentNetworkProvider = {
provider_id: string;
};
/** List the Agent Network provider catalog (server-defined provider types). */
export async function listAgentNetworkCatalog(
page: Page,
): Promise<AgentNetworkCatalogProvider[]> {
@@ -502,16 +459,19 @@ export async function listAgentNetworkCatalog(
);
}
/** List connected Agent Network providers. */
export async function listAgentNetworkProviders(
page: Page,
): Promise<AgentNetworkProvider[]> {
return apiGet<AgentNetworkProvider[]>(page, "/agent-network/providers");
}
/** Delete an Agent Network provider by ID. */
export async function deleteAgentNetworkProviderById(page: Page, id: string) {
await apiDelete(page, `/agent-network/providers/${id}`);
}
/** Delete all Agent Network providers whose name starts with the prefix. */
export async function deleteAgentNetworkProvidersByPrefix(
page: Page,
prefix: string,
@@ -523,18 +483,3 @@ export async function deleteAgentNetworkProvidersByPrefix(
}
}
}
// Only builds with POST /agent-network/settings expose proxy_address.
export async function supportsAgentNetworkSettingsBootstrap(
page: Page,
): Promise<boolean> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.get(`${origin}/api/agent-network/settings`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok()) return false;
const settings = await resp.json().catch(() => null);
return (
!!settings && typeof settings === "object" && "proxy_address" in settings
);
}
+1 -9
View File
@@ -117,15 +117,7 @@ export async function loginToApp(
* Use this instead of page.goto() for in-app navigation after loginToApp().
*/
export async function navigateTo(page: Page, path: string) {
// A client-side route update still in flight from the previous test (the
// dashboard page is shared per worker) aborts the navigation. Retry once —
// by then the app-driven navigation has landed.
try {
await page.goto(path, { waitUntil: "domcontentloaded" });
} catch (e) {
if (!String(e).includes("ERR_ABORTED")) throw e;
await page.goto(path, { waitUntil: "domcontentloaded" });
}
await page.goto(path, { waitUntil: "domcontentloaded" });
const modal = page.getByTestId("setup-netbird-modal");
try {
await modal.waitFor({ state: "visible", timeout: 3_000 });
-235
View File
@@ -1,235 +0,0 @@
import { Page, Locator, expect } from "@playwright/test";
import { navigateTo } from "./auth";
import { clearScrollLock, setTestEdition } from "./utils";
// The test build's billing modal backdrop swallows canvas clicks.
async function disableCloudBilling(page: Page) {
await setTestEdition(page, "licensed");
}
export async function dismissBlockingOverlays(page: Page) {
await clearScrollLock(page);
}
export type FlowView = "peers" | "users" | "groups" | "networks";
export async function openControlCenter(page: Page, tab?: FlowView) {
await disableCloudBilling(page);
await navigateTo(
page,
tab ? `/control-center?tab=${tab}` : "/control-center",
);
// The pane hides behind cc-prefit until the first fitView.
await expect(page.locator(".react-flow__pane")).toBeVisible();
await dismissBlockingOverlays(page);
}
export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true });
await expect(page.getByTestId(`cc-flow-${view}`)).toHaveAttribute(
"data-state",
"active",
);
}
export async function enterDraft(page: Page) {
await dismissBlockingOverlays(page);
await page.getByTestId("cc-mode-draft").click({ force: true });
await page.getByTestId("cc-draft-use-current-option").click({ force: true });
// Waiting for the Add button also guarantees draft mode is fully active.
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
}
// Draft state is React-only, so a full page load discards it.
export async function resetDraftState(page: Page) {
await openControlCenter(page);
}
export async function readDraftChanges(page: Page): Promise<any[]> {
return await page.evaluate(
() =>
(window as unknown as { __ccDraftChanges?: any[] }).__ccDraftChanges ??
[],
);
}
async function centerOf(locator: Locator) {
const box = await locator.boundingBox();
if (!box) throw new Error("element has no layout");
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
async function mouseDrag(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
// Intermediate moves so drag thresholds see a realistic pointer path.
const steps = 12;
for (let i = 1; i <= steps; i++) {
await page.mouse.move(
from.x + ((to.x - from.x) * i) / steps,
from.y + ((to.y - from.y) * i) / steps,
);
}
await page.mouse.up();
// Let the drop handler commit the new node before callers assert on it.
await page.waitForTimeout(120);
}
// Networks share the Resources tab.
function categoryForTemplate(templateTestId: string): string {
if (templateTestId.includes("peer")) return "peers";
if (templateTestId.includes("policy")) return "policies";
if (templateTestId.includes("group")) return "groups";
return "resources";
}
/** The panel drags via raw pointer events, not HTML5 DnD. */
export async function dragTemplateToCanvas(
page: Page,
templateTestId: string,
target?: { x: number; y: number },
opts: { search?: string } = {},
) {
const item = page.getByTestId(templateTestId);
// The panel only fades out, so read the toggle's state from aria-pressed.
const addButton = page.getByTestId("cc-toolbar-add");
if ((await addButton.getAttribute("aria-pressed")) !== "true") {
await addButton.click();
await expect(addButton).toHaveAttribute("aria-pressed", "true");
}
// Dispatch directly: the tab's hover tooltip intercepts a real pointer click.
await page
.getByTestId(`cc-category-${categoryForTemplate(templateTestId)}`)
.dispatchEvent("click");
// Picking a category clears the search box, so fill it after the switch.
if (opts.search) {
await page.getByPlaceholder(/Search components/).fill(opts.search);
}
await expect(item).toBeVisible();
await item.scrollIntoViewIfNeeded();
const pane = page.locator(".react-flow__pane");
const paneBox = await pane.boundingBox();
if (!paneBox) throw new Error("canvas pane not laid out");
const to = target ?? {
// Land right of the panel, roughly canvas center.
x: paneBox.x + paneBox.width * 0.6,
y: paneBox.y + paneBox.height * 0.5,
};
await mouseDrag(page, await centerOf(item), to);
// A resource drop opens an editor modal that Escape would dismiss.
if (!templateTestId.includes("resource")) {
await page.keyboard.press("Escape");
}
}
export function canvasNode(page: Page, dataIdPrefix: string) {
return page.locator(`.react-flow__node[data-id^="${dataIdPrefix}"]`);
}
/** `sr` (right) makes the node a policy SOURCE, `sl` (left) a DESTINATION. */
export async function connectNodes(
page: Page,
source: Locator,
target: Locator,
handleSide: "sr" | "sl" = "sr",
) {
// Connect handles fade in on node hover, so wait for a stable position.
await source.hover();
const handle = source.locator(
`.react-flow__handle[data-handleid="${handleSide}-connect"]`,
);
await expect(handle).toBeVisible();
await page.waitForTimeout(100);
// The target's handle only becomes connectable mid-drag, so pace and dwell.
const from = await centerOf(handle);
const to = await centerOf(target);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.waitForTimeout(50);
const steps = 20;
for (let i = 1; i <= steps; i++) {
await page.mouse.move(
from.x + ((to.x - from.x) * i) / steps,
from.y + ((to.y - from.y) * i) / steps,
);
await page.waitForTimeout(12);
}
await page.mouse.move(to.x, to.y);
await page.waitForTimeout(80);
await page.mouse.up();
await page.waitForTimeout(120);
}
export async function panePoint(page: Page, fx: number, fy: number) {
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("canvas pane not laid out");
return { x: pane.x + pane.width * fx, y: pane.y + pane.height * fy };
}
export async function createViaCanvasMenu(
page: Page,
action: string,
at?: { fx: number; fy: number },
) {
const point = await panePoint(page, at?.fx ?? 0.6, at?.fy ?? 0.5);
await page.mouse.move(point.x, point.y);
await page.mouse.click(point.x, point.y, { button: "right" });
const menu = page.getByTestId("cc-canvas-context-menu");
await expect(menu).toBeVisible();
await menu.getByTestId(`cc-canvas-menu-${action}`).click();
}
export async function readDraftCanvas(page: Page): Promise<any | null> {
return await page.evaluate(
() =>
(window as unknown as { __ccDraftCanvas?: unknown }).__ccDraftCanvas ??
null,
);
}
export async function dragNodeOnto(page: Page, node: Locator, target: Locator) {
await mouseDrag(page, await centerOf(node), await centerOf(target));
}
export async function clickContextMenuItem(
page: Page,
node: Locator,
action: string,
) {
await dismissBlockingOverlays(page);
// The menu's items detach when the node underneath it updates, so retry.
await expect(async () => {
await node.click({ button: "right" });
const menu = page.getByTestId("cc-node-context-menu");
await expect(menu).toBeVisible({ timeout: 2000 });
await menu.getByTestId(`cc-menu-${action}`).click({ timeout: 2000 });
}).toPass();
}
export function reviewButton(page: Page) {
return page.getByTestId("cc-draft-review");
}
export async function expectChangeCount(page: Page, count: number) {
if (count === 0) {
await expect(reviewButton(page)).toBeDisabled();
} else {
await expect(reviewButton(page)).toBeEnabled();
await expect(reviewButton(page)).toContainText(String(count));
}
}
/** Assumes both policy sides are already filled in. */
export async function submitCreatePolicyModal(page: Page, name?: string) {
await page.getByTestId("policy-continue").click();
await page.getByTestId("policy-continue").click();
if (name) {
await page.getByTestId("policy-name").fill(name);
}
await page.getByTestId("submit-policy").click();
}
-94
View File
@@ -1,94 +0,0 @@
// Spins up a real NetBird peer in docker, registering via a setup key. The test
// env has no signal/TURN, so the peer registers but stays offline. The client
// MUST reach management directly (http://management:80); caddy breaks h2c.
import { execFileSync } from "child_process";
import { expect, type Page } from "@playwright/test";
import {
createSetupKey,
deletePeersByPrefix,
deleteSetupKeysByPrefix,
listPeers,
} from "./api";
const NETWORK = "environment_netbird";
const IMAGE = "netbirdio/netbird:latest";
const MGMT_URL = "http://management:80";
type RegisteredPeer = {
id: string;
name: string;
hostname: string;
connected: boolean;
};
// argv, not a shell string: the setup key comes back from the management API
// and must never be parsed by a shell.
function docker(args: string[]): string {
return execFileSync("docker", args, { stdio: ["ignore", "pipe", "pipe"] })
.toString()
.trim();
}
// `hostname` doubles as the container name and the peer's account name.
export async function registerDockerPeer(
page: Page,
hostname: string,
autoGroupIds: string[] = [],
): Promise<RegisteredPeer> {
const key = await createSetupKey(page, hostname, autoGroupIds);
return runDockerPeerWithKey(page, hostname, key.key);
}
// Runs a peer with an already-generated setup key instead of minting one.
export async function runDockerPeerWithKey(
page: Page,
hostname: string,
key: string,
): Promise<RegisteredPeer> {
removeDockerContainer(hostname);
docker([
"run",
"-d",
"--name",
hostname,
"--network",
NETWORK,
"--cap-add=NET_ADMIN",
"--cap-add=SYS_ADMIN",
"-e",
`NB_SETUP_KEY=${key}`,
"-e",
`NB_MANAGEMENT_URL=${MGMT_URL}`,
"-e",
`NB_HOSTNAME=${hostname}`,
IMAGE,
]);
let found: RegisteredPeer | undefined;
await expect
.poll(
async () => {
found = ((await listPeers(page)) as RegisteredPeer[]).find(
(p) => p.name === hostname,
);
return !!found;
},
{ timeout: 45_000, intervals: [2000, 2000, 3000] },
)
.toBe(true);
return found!;
}
// Peer and setup-key cleanup is the caller's responsibility.
export function removeDockerContainer(hostname: string) {
try {
docker(["rm", "-f", hostname]);
} catch {
/* none */
}
}
export async function cleanupDockerPeer(page: Page, hostname: string) {
removeDockerContainer(hostname);
await deletePeersByPrefix(page, hostname);
await deleteSetupKeysByPrefix(page, hostname);
}
+24 -22
View File
@@ -5,8 +5,16 @@ export function generateRandomName(prefix?: string): string {
}
/**
* Run an action and wait until every request matching `pattern` has settled,
* plus a quiet window to catch chains where one response triggers the next.
* Run an action (click, goto, ...) and wait until every API request whose
* URL contains `pattern` has finished (response received or failed), plus a
* short quiet window to catch request chains where one response triggers
* the next fetch.
*
* Use this to make navigation deterministic: e.g. when opening the services
* page, the table only renders fully after /api/reverse-proxies/* calls
* return, so asserting on rows right after the click races the backend.
*
* Returns whatever the action returns.
*/
export async function waitForApiCalls<T>(
page: Page,
@@ -41,6 +49,8 @@ export async function waitForApiCalls<T>(
try {
const result = await action();
const deadline = Date.now() + timeoutMs;
// Wait until: at least one matching request was seen (unless none ever
// fires), none are in flight, and the network has been quiet for quietMs.
while (Date.now() < deadline) {
const quietFor = Date.now() - lastActivity;
if (inFlight === 0 && quietFor >= quietMs) {
@@ -56,6 +66,11 @@ export async function waitForApiCalls<T>(
}
}
/**
* Apply a single-choice (radio) table filter via the new TableFilters UI:
* open the "Filters" popover, pick the filter by column id, then select the
* option by its visible label (e.g. "Active", "Inactive", "All").
*/
export async function applyRadioTableFilter(
page: Page,
filterId: string,
@@ -70,32 +85,19 @@ export async function applyRadioTableFilter(
}
/**
* Call before navigating. The override persists for the whole browser context
* and the page is shared per worker, so edition-dependent specs must set it.
*/
export async function setTestEdition(
page: Page,
edition: "cloud" | "licensed" | "oss",
) {
await page.evaluate((ed) => {
try {
window.localStorage.setItem("netbird-test-edition", ed);
} catch (e) {
/* storage unavailable */
}
}, edition);
}
/**
* Some Radix modals leave scroll-lock, `pointer-events: none` or a stale
* overlay div behind, blocking the entire page.
* Clear stale Radix scroll-lock and overlay from body.
* Some Radix modals leave `data-scroll-locked`, `pointer-events: none`,
* or a stale overlay div blocking the entire page.
*/
export async function clearScrollLock(page: Page) {
await page.evaluate(() => {
document.body.removeAttribute("data-scroll-locked");
document.body.style.removeProperty("pointer-events");
// Remove stale Radix dialog overlays that block pointer events
document
.querySelectorAll('div[data-state="open"].fixed[class*="backdrop-blur"]')
.querySelectorAll(
'div[data-state="open"].fixed[class*="backdrop-blur"]',
)
.forEach((el) => el.remove());
});
}
+2 -40
View File
@@ -10,10 +10,7 @@
* The kimi_api catalog entry ships with newer management builds. When the
* backend under test predates it, the whole suite skips instead of failing —
* same trade-off as the provider matrix in netbird's agent-network e2e
* harness, which skips per-provider on missing credentials. The same applies
* to the explicit settings bootstrap: connecting the first provider of an
* account POSTs /agent-network/settings first, so on a build without that
* endpoint the wizard cannot get as far as creating a provider.
* harness, which skips per-provider on missing credentials.
*
* The Agent Network menu is deployment-gated; the test build honors the
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
@@ -25,7 +22,6 @@ import { generateRandomName } from "../helpers/utils";
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
supportsAgentNetworkSettingsBootstrap,
} from "../helpers/api";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
@@ -66,11 +62,6 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
!catalog.some((c) => c.id === KIMI_CATALOG_ID),
`management catalog has no ${KIMI_CATALOG_ID} entry`,
);
test.skip(
!(await supportsAgentNetworkSettingsBootstrap(page)),
"management build has no POST /agent-network/settings, so the " +
"wizard cannot bootstrap the account before the first create",
);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
@@ -119,40 +110,11 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
resp.request().method() === "POST",
{ timeout: 30_000 },
);
// On the account's first provider the wizard bootstraps the settings row
// first and only creates the provider once that succeeds. A rejected
// bootstrap therefore means no provider POST ever happens, which on its
// own surfaces only as the whole test timing out — race it so the real
// status is reported instead. 409 is not a rejection: it means a
// concurrent bootstrap won and the row exists, which the wizard treats as
// success and follows with the provider create, so matching it here would
// fail a run that is about to succeed.
const bootstrapRejected: Promise<never> = page
.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/settings") &&
resp.request().method() === "POST" &&
resp.status() !== 409 &&
!resp.ok(),
{ timeout: 30_000 },
)
.then(
(resp) => {
throw new Error(
`Agent Network settings bootstrap POST returned ${resp.status()}; ` +
"the provider was never created",
);
},
// Nothing matched: the bootstrap succeeded or wasn't needed. Never
// settle, leaving the race to the provider create.
() => new Promise<never>(() => {}),
);
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
const created = await Promise.race([createResponse, bootstrapRejected]);
expect([200, 201]).toContain(created.status());
expect([200, 201]).toContain((await createResponse).status());
// Row lands in the providers table.
await expect(page.getByText(providerName).first()).toBeVisible();
@@ -1,145 +0,0 @@
/**
* Agent Network settings wire-shape spec.
*
* The management API signals "account not bootstrapped" differently by
* generation: current servers answer GET /agent-network/settings with the
* defaults object carrying an empty endpoint/proxy_address, older ones with
* 200 + a JSON null body, and the oldest with a 404.
* useAgentNetworkSettings normalizes all three to the same null-settings
* signal, and this spec pins that: the providers page must render the
* connect-first endpoint placeholder for every unbootstrapped shape, and the
* endpoint badge once the account is bootstrapped. The settings route is
* mocked per test, so the assertions do not depend on the backend build or
* on account state left behind by other suites.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const EMPTY_STATE_TEXT =
"Connect your first provider to set up your agent network endpoint.";
const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io";
// The defaults object current servers return for an account that has not been
// bootstrapped yet: the mutable settings carry their default values, the
// endpoint/proxy_address identity is empty, and there are no timestamps because
// no row has been persisted.
const UNBOOTSTRAPPED_DEFAULTS = {
endpoint: "",
proxy_address: "",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const BOOTSTRAPPED_SETTINGS = {
endpoint: BOOTSTRAPPED_ENDPOINT,
proxy_address: "eu.proxy.netbird.io",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
async function mockSettingsResponse(
page: Page,
response: { status: number; body: string },
) {
await page.route("**/api/agent-network/settings", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: response.status,
contentType: "application/json",
body: response.body,
});
});
}
async function openProvidersPage(
browser: Browser,
settingsResponse: { status: number; body: string },
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
// Close the context on any setup failure — the caller only receives the
// close callback once setup succeeds.
try {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await mockSettingsResponse(page, settingsResponse);
await loginToApp(page, "owner");
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
return { page, close: () => context.close() };
} catch (e) {
await context.close();
throw e;
}
}
const UNBOOTSTRAPPED_SHAPES: {
name: string;
response: { status: number; body: string };
}[] = [
{
name: "defaults object with empty endpoint (current servers)",
response: { status: 200, body: JSON.stringify(UNBOOTSTRAPPED_DEFAULTS) },
},
{
name: "200 with JSON null body (older servers)",
response: { status: 200, body: "null" },
},
{
name: "404 (oldest servers)",
response: {
status: 404,
body: JSON.stringify({ message: "settings not found", code: 404 }),
},
},
];
test.describe("Agent Network settings wire shapes @agent-network", () => {
for (const shape of UNBOOTSTRAPPED_SHAPES) {
test(`unbootstrapped account renders the empty state: ${shape.name}`, async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, shape.response);
try {
await expect(page.getByText(EMPTY_STATE_TEXT)).toBeVisible();
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toHaveCount(0);
} finally {
await close();
}
});
}
test("bootstrapped account renders the endpoint instead of the empty state", async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, {
status: 200,
body: JSON.stringify(BOOTSTRAPPED_SETTINGS),
});
try {
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toBeVisible();
await expect(page.getByText(EMPTY_STATE_TEXT)).toHaveCount(0);
} finally {
await close();
}
});
});
-88
View File
@@ -1,88 +0,0 @@
import { expect, type Response } from "@playwright/test";
import { test } from "../helpers/fixtures";
import { deleteGroup, deletePolicyById, listPolicies } from "../helpers/api";
import {
canvasNode,
connectNodes,
createViaCanvasMenu,
enterDraft,
expectChangeCount,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
test.describe.serial("Control Center Deploy @control-center", () => {
test.beforeEach(async ({ dashboardAsOwner: page }) => {
await resetDraftState(page);
});
test("Deploys a group+group+policy draft to the account in dependency order", async ({
dashboardAsOwner: page,
}) => {
test.setTimeout(60_000);
await enterDraft(page);
await createViaCanvasMenu(page, "new-group", { fx: 0.3, fy: 0.4 });
await createViaCanvasMenu(page, "new-group", { fx: 0.3, fy: 0.7 });
const groups = canvasNode(page, "group-new-");
await expect(groups).toHaveCount(2);
await createViaCanvasMenu(page, "new-policy", { fx: 0.65, fy: 0.5 });
const policy = canvasNode(page, "policy-new-");
await expect(policy).toHaveCount(1);
// g0 as source, g1 as destination → a complete, trackable policy.
await connectNodes(page, groups.nth(0), policy, "sr");
await connectNodes(page, groups.nth(1), policy, "sl");
await expectChangeCount(page, 3);
// The canvas assigns generic names, so capture the ids for exact cleanup.
const createdGroupIds: string[] = [];
const createdPolicyIds: string[] = [];
const collectCreatedIds = async (resp: Response) => {
if (resp.request().method() !== "POST") return;
const url = resp.url();
if (url.includes("/api/groups")) {
const b = await resp.json().catch(() => null);
if (b?.id) createdGroupIds.push(b.id);
} else if (url.includes("/api/policies")) {
const b = await resp.json().catch(() => null);
if (b?.id) createdPolicyIds.push(b.id);
}
};
page.on("response", collectCreatedIds);
await reviewButton(page).click();
await expect(page.getByTestId("cc-deploy")).toBeVisible();
await page.getByTestId("cc-deploy").click({ force: true });
// Deploy finishes by exiting the draft back to live.
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible({
timeout: 30_000,
});
try {
await expect.poll(() => createdGroupIds.length).toBe(2);
await expect.poll(() => createdPolicyIds.length).toBe(1);
// Real group ids in the deployed policy prove the groups were created
// before it.
const policies = await listPolicies(page);
const deployed = policies.find((p) => p.id === createdPolicyIds[0]);
expect(deployed).toBeTruthy();
const rule = deployed!.rules[0];
const refIds = [
...(rule.sources ?? []),
...(rule.destinations ?? []),
].map((x: any) => (typeof x === "string" ? x : x?.id));
for (const gid of createdGroupIds) expect(refIds).toContain(gid);
} finally {
// The owner page is worker-scoped, so an attached listener would keep
// reading response bodies for every later test on this worker.
page.off("response", collectCreatedIds);
// Policy first: a group in use can't be deleted.
for (const pid of createdPolicyIds) await deletePolicyById(page, pid);
for (const gid of createdGroupIds) await deleteGroup(page, gid);
}
});
});
@@ -1,321 +0,0 @@
import { expect } from "@playwright/test";
import { test } from "../helpers/fixtures";
import { createGroup, deleteGroup, listGroups } from "../helpers/api";
import { generateRandomName } from "../helpers/utils";
import {
canvasNode,
clickContextMenuItem,
connectNodes,
dragNodeOnto,
dragTemplateToCanvas,
enterDraft,
expectChangeCount,
openControlCenter,
readDraftChanges,
resetDraftState,
reviewButton,
submitCreatePolicyModal,
} from "../helpers/control-center";
test.describe.serial("Control Center Draft Connections @control-center", () => {
test.beforeEach(async ({ dashboardAsOwner: page }) => {
await resetDraftState(page);
});
test("Should create a policy by connecting two groups", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
// Two groups, placed apart so the connect drag has a clear path.
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await expect(groups).toHaveCount(2);
await expectChangeCount(page, 2);
await connectNodes(page, groups.nth(0), groups.nth(1));
await expect(page.getByTestId("create-policy-title")).toBeVisible();
await submitCreatePolicyModal(page);
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
await expect(page.locator(".react-flow__edge")).toHaveCount(2);
await expectChangeCount(page, 3);
const changes = await readDraftChanges(page);
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(1);
});
test("Should track a placeholder-peer policy but block its deploy on the peer install", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-peer-server", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const group = canvasNode(page, "group-new-").first();
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
await expect(peer).toHaveCount(1);
await connectNodes(page, peer, group);
await expect(page.getByTestId("create-policy-title")).toBeVisible();
await submitCreatePolicyModal(page);
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
// The placeholder's install-peer change gates the deploy, not the policy.
const changes = await readDraftChanges(page);
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(1);
expect(changes.filter((c) => c.type === "install-peer")).toHaveLength(1);
await expectChangeCount(page, 3);
});
test("Should rename a group and propagate the name into the policy changeset", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await submitCreatePolicyModal(page);
await expectChangeCount(page, 3);
const sourceGroup = groups.nth(0);
const newName = generateRandomName("cc-renamed-");
await clickContextMenuItem(page, sourceGroup, "rename");
await page.getByTestId("cc-rename-input").fill(newName);
await page.getByTestId("cc-rename-submit").click();
await expect(sourceGroup).toContainText(newName);
// The rename folds into the create-group entry instead of adding one.
await expectChangeCount(page, 3);
const changes = await readDraftChanges(page);
expect(
changes.some((c) => c.type === "create-group" && c.name === newName),
).toBe(true);
const policy = changes.find((c) => c.type === "create-policy");
const rule = policy.policy.rules[0];
const referenced = [
...(rule.sources ?? []),
...(rule.destinations ?? []),
].map((g) => (typeof g === "string" ? g : g.name));
expect(referenced).toContain(newName);
// The editor seeds from the canvas policy node, so it must show the rename.
const policyNode = canvasNode(page, "policy-new-");
await clickContextMenuItem(page, policyNode, "edit");
await expect(page.getByTestId("source-group-selector")).toContainText(
newName,
);
await page.keyboard.press("Escape");
});
test("Should create a group inline in the policy modal and show it on canvas", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await expect(page.getByTestId("create-policy-title")).toBeVisible();
const inlineName = generateRandomName("cc-inline-");
const selector = page.getByTestId("destination-group-selector");
const search = page.getByTestId("destination-group-selector-search");
// The popover open can be swallowed while the modal is still animating in.
await expect(async () => {
await selector.click();
await expect(search).toBeVisible({ timeout: 1000 });
}).toPass();
await search.fill(inlineName);
await page.keyboard.press("Enter");
// Escape would close the whole modal once the popover is already gone.
await page.getByTestId("create-policy-title").click();
await expect(search).toBeHidden();
await submitCreatePolicyModal(page);
await expect(
page.locator(".react-flow__node", { hasText: inlineName }),
).toHaveCount(1);
const changes = await readDraftChanges(page);
expect(
changes.some((c) => c.type === "create-group" && c.name === inlineName),
).toBe(true);
});
test("Should edit a draft policy and reflect the change on the canvas", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await submitCreatePolicyModal(page);
const policyNode = canvasNode(page, "policy-new-");
await expect(policyNode).toHaveCount(1);
const newName = generateRandomName("cc-policy-");
await clickContextMenuItem(page, policyNode, "edit");
await expect(page.getByTestId("update-policy-title")).toBeVisible();
await page.getByRole("tab", { name: "Name & Description" }).click();
await page.getByTestId("policy-name").fill(newName);
await page.getByTestId("submit-policy").click();
await expect(policyNode).toContainText(newName);
const changes = await readDraftChanges(page);
const policy = changes.find((c) => c.type === "create-policy");
expect(policy.policy.name).toBe(newName);
});
test("Should remove a draft policy from the canvas without a confirmation", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const groups = canvasNode(page, "group-new-");
await connectNodes(page, groups.nth(0), groups.nth(1));
await submitCreatePolicyModal(page);
await expectChangeCount(page, 3);
const policyNode = canvasNode(page, "policy-new-");
await clickContextMenuItem(page, policyNode, "remove");
await expect(page.getByTestId("confirmation.confirm")).not.toBeVisible();
await expect(policyNode).toHaveCount(0);
await expect(page.locator(".react-flow__edge")).toHaveCount(0);
await expect(groups).toHaveCount(2);
await expectChangeCount(page, 2);
});
test("Should drop a peer into a group and track the membership", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("no pane");
await dragTemplateToCanvas(page, "cc-template-group", {
x: pane.x + pane.width * 0.45,
y: pane.y + pane.height * 0.35,
});
await dragTemplateToCanvas(page, "cc-template-peer-server", {
x: pane.x + pane.width * 0.75,
y: pane.y + pane.height * 0.65,
});
const group = canvasNode(page, "group-new-").first();
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
await expect(peer).toHaveCount(1);
await dragNodeOnto(page, peer, group);
await expect(peer).toHaveCount(0);
await expect(group).toContainText(/1\s*peer/i);
const changes = await readDraftChanges(page);
const createGroupChange = changes.find((c) => c.type === "create-group");
expect(createGroupChange.peerIds).toHaveLength(1);
await expectChangeCount(page, 2);
// The placeholder isn't in the API peer list but must still be listed.
await clickContextMenuItem(page, group, "view-details");
await expect(
page.getByText("Server", { exact: false }).last(),
).toBeVisible();
await page.keyboard.press("Escape");
});
test("Should delete an existing group in draft and deploy the deletion", async ({
dashboardAsOwner: page,
}) => {
// The API helper navigates away to capture the token, so re-open the canvas.
const name = generateRandomName("cc-delete-");
const seeded = await createGroup(page, name);
await openControlCenter(page);
await enterDraft(page);
// Search narrows the virtualized list so the row actually renders.
await dragTemplateToCanvas(page, `cc-panel-group-${seeded.id}`, undefined, {
search: name,
});
const groupNode = page.locator(
`.react-flow__node[data-id="group-${seeded.id}"]`,
);
await expect(groupNode).toHaveCount(1);
await clickContextMenuItem(page, groupNode, "delete");
await expect(page.getByTestId("confirmation.title")).toContainText(name);
await page.getByTestId("confirmation.confirm").click();
await expect(groupNode).toHaveCount(0);
await expectChangeCount(page, 1);
await reviewButton(page).click();
const deleteResponse = page.waitForResponse(
(resp) =>
resp.url().includes(`/api/groups/${seeded.id}`) &&
resp.request().method() === "DELETE",
{ timeout: 30_000 },
);
await page.getByTestId("cc-deploy").click({ force: true });
const response = await deleteResponse;
expect(response.status()).toBeLessThan(300);
const remaining = await listGroups(page);
expect(remaining.some((g) => g.id === seeded.id)).toBe(false);
});
// Reuses the worker page: a fresh context's login can outrun the hook timeout.
test.afterAll(async ({ dashboardAsOwner: page }) => {
const groups = await listGroups(page).catch(() => []);
for (const g of groups) {
if (g.name.startsWith("cc-delete-") || g.name.startsWith("cc-inline-")) {
await deleteGroup(page, g.id).catch(() => {});
}
}
});
});
@@ -1,154 +0,0 @@
import { expect } from "@playwright/test";
import { test } from "../helpers/fixtures";
import {
canvasNode,
createViaCanvasMenu,
enterDraft,
resetDraftState,
} from "../helpers/control-center";
test.describe.serial("Control Center Draft Extras @control-center", () => {
test.beforeEach(async ({ dashboardAsOwner: page }) => {
await resetDraftState(page);
});
test("New Network (canvas menu) adds a draft frame with its frame actions", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await createViaCanvasMenu(page, "new-network");
const frame = canvasNode(page, "network-new-");
await expect(frame).toHaveCount(1);
await frame.click({ button: "right" });
const menu = page.getByTestId("cc-node-context-menu");
await expect(menu).toBeVisible();
for (const action of [
"edit",
"add-resource",
"add-resource-group",
"add-routing-peer",
"remove",
]) {
await expect(menu.getByTestId(`cc-menu-${action}`)).toBeVisible();
}
});
test("Auto Arrange repositions overlapping nodes", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
// Two groups created at the viewport centre land on top of each other.
await page.locator(".react-flow__pane").click();
await page.keyboard.press("Alt+4");
await page.keyboard.press("Alt+4");
const groups = canvasNode(page, "group-new-");
await expect(groups).toHaveCount(2);
await page.getByTestId("cc-toolbar-arrange").click();
await expect
.poll(async () => {
const a = await groups.nth(0).boundingBox();
const b = await groups.nth(1).boundingBox();
if (!a || !b) return false;
return Math.abs(a.x - b.x) > 5 || Math.abs(a.y - b.y) > 5;
})
.toBe(true);
});
test("Editing a draft network frame renames it on the canvas", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await createViaCanvasMenu(page, "new-network");
const frame = canvasNode(page, "network-new-");
await expect(frame).toHaveCount(1);
await frame.click({ button: "right" });
const menu = page.getByTestId("cc-node-context-menu");
await expect(menu).toBeVisible();
await menu.getByTestId("cc-menu-edit").click();
const newName = "Renamed Draft Net";
await page.getByTestId("network-name-input").fill(newName);
await page.getByTestId("submit-network").click({ force: true });
await expect(frame).toContainText(newName);
});
test("A draft resource can be disabled and re-enabled via its context menu", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await createViaCanvasMenu(page, "new-resource");
await page.getByTestId("resource-name-input").fill("toggle-res");
await page.getByTestId("resource-address-input").fill("10.5.6.7/32");
await page.getByTestId("submit-resource").click({ force: true });
const resource = canvasNode(page, "resource-new-");
await expect(resource).toHaveCount(1);
const openMenu = async () => {
await resource.click({ button: "right" });
const menu = page.getByTestId("cc-node-context-menu");
await expect(menu).toBeVisible();
return menu;
};
let menu = await openMenu();
await menu.getByTestId("cc-menu-disable").click();
menu = await openMenu();
await expect(menu.getByTestId("cc-menu-enable")).toBeVisible();
await menu.getByTestId("cc-menu-enable").click();
menu = await openMenu();
await expect(menu.getByTestId("cc-menu-disable")).toBeVisible();
await page.keyboard.press("Escape");
});
test("Add Routing Peer on a draft network frame opens the routing-peer modal", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await createViaCanvasMenu(page, "new-network");
const frame = canvasNode(page, "network-new-");
await expect(frame).toHaveCount(1);
await frame.click({ button: "right" });
const menu = page.getByTestId("cc-node-context-menu");
await expect(menu).toBeVisible();
await menu.getByTestId("cc-menu-add-routing-peer").click();
await expect(page.getByTestId("routing-peer-tab-peer")).toBeVisible();
await expect(page.getByTestId("routing-peer-tab-group")).toBeVisible();
await page.keyboard.press("Escape");
});
test("A draft resource can be renamed via its context menu", async ({
dashboardAsOwner: page,
}) => {
await enterDraft(page);
await createViaCanvasMenu(page, "new-resource");
await page.getByTestId("resource-name-input").fill("orig-res");
await page.getByTestId("resource-address-input").fill("10.9.9.9/32");
await page.getByTestId("submit-resource").click({ force: true });
const resource = canvasNode(page, "resource-new-");
await expect(resource).toHaveCount(1);
await resource.click({ button: "right" });
const menu = page.getByTestId("cc-node-context-menu");
await expect(menu).toBeVisible();
await menu.getByTestId("cc-menu-rename").click();
await page.getByTestId("cc-rename-input").fill("renamed-res");
await page.getByTestId("cc-rename-submit").click({ force: true });
await expect(resource).toContainText("renamed-res");
});
});
Loaded 20 of 300 files, more files were not shown because too many files have changed in this diff. Show more