Compare commits

...
Author SHA1 Message Date
Viktor Liu e3b077d096 Bump the WASM client to v0.78.0 2026-08-29 09:14:24 +02:00
Viktor Liu f7c0cc2a5d Report the registered peer name as the WASM client's device name 2026-08-29 09:14:24 +02:00
Brandon Hopkins 4f905a4368 Add bulk deletion for Access Control groups (#752)
* Add multi-select bulk deletion for Access Control groups

* Add indeterminate group selection and permission-safe column visibility

* Background rule for checkbox component

* Better show when disabled
2026-08-27 17:32:25 +02:00
Eduard Gert 5cf8995c65 Detect Entra connection from auth service SSO connections (#776) 2026-08-27 11:51:27 +02:00
Eduard Gert ca16b4868b Add control center draft mode (#744) 2026-08-27 10:08:22 +02:00
Shaban Mohammedsaani Hassan d3ff3bafca fix: use --restart=unless-stopped instead of --rm in Docker install command (#760)
The generated Docker command used --rm, which removes the container on
stop. Combined with -d, the NetBird client would not survive a host
reboot despite the persistent netbird-client volume. Replace with
--restart=unless-stopped so the client reconnects automatically.
2026-08-27 09:51:35 +02:00
4ca8cc09a4 Fix typo in PeerExpirationToggle component (#665)
* Fix typo in PeerExpirationToggle component

* Fix the same typo in PeerActionCell

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:52:28 +02:00
Nicolas Frati 9e6d512ed1 ci: skip Docker publishing for community PRs (#775) 2026-08-26 14:40:03 +02:00
Viktor Liu 7b60d87652 Add a workflow bumping the WASM client on netbird releases (#770)
* Add a workflow bumping the WASM client on netbird releases

* Cover the pinned WASM client with an instantiation test

* Connect the pinned WASM client to management in the e2e suite

* Validate the dispatched tag before it reaches the shell and satisfy lint

* Sort the spec imports

* Clean up the registered peer even when the connect test fails

* Drive the dashboard's own remote-access flow instead of the client directly

* Mint the flow test's target peer by exposing a service

* Limit the wasm check to what the test environment can prove

* Give the wasm download and compile a realistic budget

* Hold the wasm client to the dashboard's initialization deadline
2026-08-26 14:18:55 +02:00
Maycon Santos d72f417ff3 Keep the prices discovery returns for a loaded model (#773)
Loading a provider's models merged anything the catalog did not already carry
by exact id, and hardcoded both rates to zero on the way in. The comment gave
the reason: the discovery response carried no prices. That stopped being true
when the endpoint began returning the same rates the proxy bills with.

Bedrock feels all of it. Its listing returns geography-prefixed ids —
eu.anthropic.claude-opus-4-7 — while the catalog holds the bare
anthropic.claude-opus-4-7, so no Bedrock model ever matches by string and every
one of them takes this branch. Selecting one filled its row with $0, and the
form then warned that the model had no cost set, while the API had reported a
rate for it. Usage against it records nothing.

Exact-id matching stays. Collapsing a geography-prefixed id onto its catalog
entry would hand back the bare form, and only the prefixed one is invocable at
AWS.
2026-08-24 10:54:42 +02:00
Maycon Santos 3be6dda6a7 Load a provider's models from the vendor in the provider modal (#767)
The model picker only offered NetBird's compiled-in catalog, which goes stale as
vendors retire models and cannot reflect what an account is actually entitled to.

Adds a "Load models from provider" action backed by
`POST /agent-network/catalog/providers/models` (netbirdio/netbird#7246). Editing a
saved provider reuses the stored credential, so the masked key never reaches the
browser; creating a new one sends the key being typed. Discovered models merge into
the catalog options, with catalog entries winning a collision because they carry
pricing. A model the backend reports as unpriced is flagged and needs confirmation
before saving, so nothing is registered at a silent zero.

Two implementation notes: the unpriced warning renders once at list level rather
than per row, and the hook uses `ignoreError: true` so a provider-specific failure
is reported inline in the modal instead of as a global toast.
2026-08-23 20:22:35 +02:00
Jack Carter 98ae943bbc feat: add required Country field to Agent Network signup form (#766)
The HubSpot form behind the self-hosted Agent Network signup now has a
required company-scoped Country/Region field, so submissions without it
are rejected. Adds a searchable Country dropdown to the signup step and
includes the value as an objectTypeId 0-2 field.

Uses a static country list instead of /locations/countries since the
geolocation database may be absent on self-hosted deployments and the
HubSpot property expects full country names, not ISO codes.
2026-08-19 10:37:13 +02:00
Misha Bragin 99f86b6421 fix(setup): match the v0.75 client UI in the management URL step (#765)
The desktop client reworked its UI in v0.75: the management server is now
  asked for on the first-run "Set up NetBird" screen and lives under
  Settings > General, so the "Settings > Advanced Settings" path the Windows
  and macOS tabs described no longer exists. Move the copy into a shared
  ManagementUrlStep, cover the already-installed case, and say "menu bar"
  instead of "system tray" on macOS.

  Fixes #732

* Remove unnecessary comment
2026-08-18 16:01:58 +02:00
Viktor Liu 0ceda40180 Show the SSH enable commands with the privileges the daemon now requires (#737) 2026-08-16 19:29:16 +02:00
Misha Bragin f4cf7458be fix(agent-network): show real provider name in access log (#758) 2026-08-16 16:44:16 +02:00
Misha Bragin 9e1c29b769 fix(version): ignore build metadata when detecting available updates (#759) 2026-08-14 14:29:42 +02:00
Eduard GertandClaude Opus 5 fb05f2c172 chore(docker): upgrade base image to Alpine 3.24 (#756)
Alpine 3.14 reached end of life in May 2023 and has received no security
fixes for roughly three years. nginx is installed from the Alpine repos
rather than pinned, so the base bump also moves nginx 1.20.2 -> 1.30.4,
certbot 1.16.0 -> 5.6.0 and supervisor 4.2.2 -> 4.3.0.

Alpine 3.24 carries security support until June 2028.

Verified by building and running this Dockerfile on 3.14, 3.23 and 3.24 and
comparing: the served index.html is byte-identical across all three, the CSP
response header hashes identically, `nginx -t` passes on the existing config,
and supervisord reaches the same state. /etc/crontabs/root and
/etc/nginx/http.d/ still exist on 3.24, so the `rm /etc/crontabs/root` step and
the config COPY paths are unaffected. Every certbot flag used by init_cert.sh
is still accepted by certbot 5.x.

Certificate issuance itself could not be exercised without a real domain, so
the certbot 1.x -> 5.x jump should get one staging issuance before rollout.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:12:03 +02:00
Eduard GertandClaude Opus 5 6cf828ce8a Upgrade Next.js to 16.3 (#755)
* chore: upgrade to Next.js 16.3

Turbopack is now the default for both `next dev` and `next build`, so the
separate `turbo` script is redundant and has been dropped.

`next build` rewrites tsconfig `moduleResolution` to "bundler", which Next 16
expects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: migrate ESLint to flat config

`next lint` was removed in Next 16, so `npm run lint` had been failing with
"Invalid project directory provided, no such directory: ./lint" since that
upgrade. eslint-config-next 16 also ships flat config only, which the old
.eslintrc.json could not consume.

Ignores .claude/ so stray worktree copies of the repo are not linted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: replace ip-cidr with ip-address to clear CVE advisories

ip-cidr pulled a vulnerable ip-address (GHSA-mwp4-54f8-5fhr, high: leading-zero
octets decoded as decimal while resolvers read them as octal, enabling SSRF;
GHSA-v2v4-37r5-5v8g, moderate: XSS in Address6 HTML methods). npm reported "no
fix available" because even the latest ip-cidr pins ip-address ^9.0.5, still
inside the vulnerable range.

ip-address 10.x was already a direct dependency and covers every use: its
isValid rejects leading-zero octets and validates mask ranges natively, and
isInSubnet replaces the one `new cidr().contains()` call site.

The new validators were differential-tested against the real ip-cidr over a
67-input corpus (134 checks) before removal, with no divergences; containment
was checked across 14 v4/v6/boundary/cross-family cases. `npm audit` now
reports 0 vulnerabilities.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: replace next-themes with an in-repo ThemeProvider

next-themes renders an inline <script> to set the theme before paint. It was
loaded through dynamic(ssr: false), so that script rendered on the client, where
React 19 never executes script tags and warns:

  Encountered a script tag while rendering React component.

Upgrading would not have helped; 0.4.6 still renders the script
unconditionally. The script was also useless here: injected after hydration, it
could not prevent a flash, and postbuild.js hoists inline scripts out of the
export anyway.

The replacement keeps the same `netbird-theme` storage key and three-mode
support, and sets the theme class on <html> in the layout instead, so the
static export ships the correct theme in its first byte with no script at all.

Dropping the dynamic(ssr: false) wrapper removed an implicit prerender bailout
for the whole provider tree, which exposed isNetBirdCloud() reading
window.location during the build-time render; it is now guarded. The exported
HTML is byte-identical either way, since the page already bails to client
rendering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: key the router children slot in the dashboard layout

React logged "Each child in a list should have a unique key prop. Check the
render method of DashboardPageContent." The App Router's segment element for
`children` arrives with a null key, which React flags once it is reconciled
alongside the Navigation sibling. Wrapping it in a keyed Fragment resolves it.

Verified in the browser both ways: with the Fragment the console is clean,
without it the warning returns.

Also applies the pending simple-import-sort fix for this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: match IPv4-mapped IPv6 hosts against IPv4 CIDRs

Regression from replacing ip-cidr. Its contains() normalised an IPv4-mapped
IPv6 address to the embedded v4 before the subnet check, so
contains("::ffff:10.0.0.5") on 10.0.0.0/24 returned true; isHostInCIDR returned
false. Verified against ip-cidr@3.1.0 directly.

This is reachable: isValidIP accepts "::ffff:10.0.0.5", so a mapped host entered
in ReverseProxyAddressInput against an IPv4 resource CIDR was wrongly rejected.

An IPv6 network still rejects a mapped host, so families do not leak. Checked
across 20 cases (6 mapped-host, 14 pre-existing), with the mapped expectations
taken from measured ip-cidr output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: derive skeleton colors from the resolved theme

SkeletonTheme had the dark palette hardcoded, so a light theme would render
dark skeletons. The light values are the ones already defined on the
feature-light-theme branch rather than new invented colors.

resolvedTheme is tracked in state and updated from the existing apply(), so the
palette also follows the OS preference under "system".

Latent today, since DarkModeToggle disables both Light and System.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:11:48 +02:00
Eduard GertandClaude Opus 5 83cb07a44c Fix icon alignment in collapsed navigation and network tab Add buttons (#748)
* fix: center icons in collapsed navigation rail

Nav items have 24px of left padding (px-3 on the li plus px-3 on the
button), so with 16px icons the icon center lands at 32px. The 70px rail
centered at 35px, leaving icons 3px off. Narrow the rail to 64px so the
two line up, and match the content offset in PageContainer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: use one plus icon across network tab Add buttons

The Resources and Routing Peers Add buttons used tabler's IconCirclePlus
while the Services tab used lucide's PlusCircle. Both were size 16, but
the two icon sets draw at different inner scales, so the glyphs read as
different sizes. Standardize on lucide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:11:33 +02:00
Viktor Liu bbfa2d3a79 Bump default wasm client to v0.76.3 (#754) 2026-08-11 17:17:00 +02:00
Brad Ison a92816b9b8 Bootstrap Agent Network settings via the explicit POST (#746)
Adopts the new Agent Network settings contract:

- The management API replaced the implicit settings bootstrap — a
  `bootstrap_cluster` hint on the first provider create — with an
  explicit POST /agent-network/settings carrying the proxy cluster
  address. The settings response now carries endpoint +
  proxy_address + dedicated instead of cluster + subdomain.

- The provider wizard keeps its UX (auto-picking a validated cluster,
  gating the first create on having one) but now bootstraps the
  settings row with the explicit POST right before the first provider
  create. A bootstrap failure keeps the wizard open and surfaces the
  error — previously a failed bootstrap was swallowed server-side and
  the account silently ended up with a provider but no endpoint. A 409
  (concurrent bootstrap won) is treated as success, since the row
  exists either way.

- Types updated accordingly; bootstrapAgentNetworkSettings is exposed
  on the providers context. The onboarding provider step reuses the
  same wizard, so it is covered by the same change.

Without this, the dashboard against a new management silently stops
bootstrapping accounts: provider create succeeds (the old hint is an
ignored unknown field), but no endpoint is ever assigned.
2026-08-11 11:32:27 +02:00
Bethuel Mmbaga 0caf754897 Update Entra ID SCIM setup wizard for the new Azure portal UI (#750) 2026-08-10 11:12:40 +02:00
Misha Bragin 49582fff74 Trim numeric build suffix from sidebar version display. (#749)
Self-hosted management versions can carry a numeric build suffix
  (e.g. 0.76.3-31256681241) that overflows the sidebar box. Show the
  semver only and move the full string into the hover tooltip alongside
  the latest version. Pre-release labels like -rc.1 are preserved.
2026-08-09 19:20:49 +02:00
Jack Carter af7745a03b chore: remove solutions engineer booking modal from onboarding (#745)
The onboarding survey opened a HubSpot booking modal for accounts with
50+ planned users. Removes the modal, its onboarding-call experiment
variants, the trigger in Onboarding.tsx, and the now-unused avatar.

Keeps src/cloud/cloud-hooks/useExperiment.ts, which this was the only
consumer of, for a future experiment.
2026-08-06 10:48:31 +02:00
Maycon Santos 19d82cc2ab Handle agent-network settings defaults response (#739)
* Handle agent-network settings defaults response

The management API now answers the settings GET with a defaults object
before the account is bootstrapped (empty cluster/subdomain/endpoint)
instead of a JSON null body. Normalize that shape to the existing null
settings signal so every consumer keeps a single not-bootstrapped check,
while still tolerating the null body and 404 from older backends.

* Pin the settings wire-shape normalization in a Playwright spec

The providers page must render the connect-first empty state for every
unbootstrapped settings shape the management API has answered with over
time (defaults object with an empty endpoint, JSON null body, 404) and
the endpoint badge once bootstrapped. The settings route is mocked per
test so the spec is independent of the backend build and of account
state left behind by other suites.

* Address review: no stale settings on 404, close context on setup failure

useFetchApi keeps the previous SWR data alongside the error, so a later
404 could expose stale settings through the memo; it now yields null
when the error is a 404 while still riding out transient failures on
the kept data. The wire-shapes spec closes its browser context when a
setup step throws before the close callback is handed to the test.
2026-08-04 01:59:34 +02:00
Misha Bragin b66cc4ccb7 Cache pricing + custom model entry (#736) 2026-08-01 15:56:24 +02:00
Zoltan Papp 1d408f2e7e Add openSUSE zypper instructions with GPG key import to the Linux tab (#735)
Zypper ignores the gpgkey= entry of a .repo file, so the yum snippet would
leave the repository key uninstalled on openSUSE. The new distro entry adds
the repository with GPG checking enabled and imports the key on refresh.
2026-07-28 17:50:09 +02:00
293 changed files with 48308 additions and 4881 deletions

No files matched your search

-13
View File
@@ -1,13 +0,0 @@
{
"extends": ["next/core-web-vitals","prettier"],
"plugins": ["simple-import-sort"],
"rules": {
"simple-import-sort/imports": [
"warn",
{
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
}
],
"simple-import-sort/exports": "warn"
}
}
+5 -2
View File
@@ -99,12 +99,14 @@ 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
@@ -116,11 +118,12 @@ jobs:
with:
context: .
file: docker/Dockerfile
push: true
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- run: |
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: |
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
+81
View File
@@ -0,0 +1,81 @@
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}."
+47 -3
View File
@@ -101,17 +101,61 @@ jobs:
echo "Using reverse-proxy tag: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Setup test environment
- 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
env:
MANAGEMENT_IMAGE_TAG: ${{ steps.management_tag.outputs.tag }}
REVERSE_PROXY_IMAGE_TAG: ${{ steps.reverse_proxy_tag.outputs.tag }}
run: cd ./e2e/environment && bash create-test-env.sh
# 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
- name: Run Playwright tests
id: playwright
run: |
set -o pipefail
npm run test:ci 2>&1 | tee playwright-output.log
npx playwright test --config=e2e/playwright.config.ts 2>&1 | tee playwright-output.log
- name: Append Playwright summary to job summary
if: always() && hashFiles('e2e/test-results/results.json') != ''
+32
View File
@@ -0,0 +1,32 @@
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,6 +44,7 @@ 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.14
FROM alpine:3.24
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
nginx curl supervisor certbot-nginx && \
+1
View File
@@ -218,6 +218,7 @@ 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
+3 -1
View File
@@ -9,4 +9,6 @@ proxy-no-ports.env
proxy-certs/
proxy-certs-no-ports/
docker-compose.yml
/machinekey
/machinekey
geo-cache/
geo-dump/
+52 -2
View File
@@ -61,7 +61,13 @@ wait_proxy_cluster() {
SERVICE_NAME=${1:-reverse-proxy}
echo -n "Waiting for $SERVICE_NAME to register with management "
set +e
local attempts=60
# 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 i
for ((i = 1; i <= attempts; i++)); do
if $DOCKER_COMPOSE_COMMAND logs "$SERVICE_NAME" 2>&1 | grep -q "Initial mapping sync complete"; then
@@ -589,6 +595,8 @@ 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
@@ -760,7 +768,41 @@ 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:
@@ -794,7 +836,7 @@ services:
- NB_TRAFFIC_FLOW_INTERVAL=20s
- NB_SINGLE_INSTANCE_MODE=true
volumes:
- netbird_management:/var/lib/netbird
- netbird_management:/var/lib/netbird${geo_mounts}
- ./management.json:/etc/netbird/management.json
command: [
"--port", "80",
@@ -845,6 +887,11 @@ 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: [
@@ -866,6 +913,9 @@ 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
+128 -73
View File
@@ -1,10 +1,5 @@
/**
* 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.
*/
// 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.
import type { Page } from "@playwright/test";
type Group = {
@@ -14,12 +9,6 @@ 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(
@@ -28,13 +17,6 @@ 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([
@@ -42,7 +24,11 @@ async function getApiContext(
(resp) => {
const req = resp.request();
if (!resp.url().includes("/api/")) return false;
const isMatch = req.method() === "GET" && resp.status() === 200;
// A tokenless 200 can arrive before the service worker injects the token.
const isMatch =
req.method() === "GET" &&
resp.status() === 200 &&
!!req.headers()["authorization"];
if (debugApi) {
// eslint-disable-next-line no-console
console.log(
@@ -53,9 +39,10 @@ async function getApiContext(
}
return isMatch;
},
{ timeout: 10_000 },
// `next dev` compiles the route on demand before any /api response comes.
{ timeout: 30_000 },
),
page.goto("/team/users"),
page.goto("/team/users", { timeout: 30_000 }),
]);
const request = response.request();
@@ -89,17 +76,46 @@ async function apiDelete(page: Page, path: string): Promise<void> {
});
}
/** List all groups. */
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();
}
export async function listGroups(page: Page): Promise<Group[]> {
return apiGet<Group[]>(page, "/groups");
}
/** Delete a group by ID. */
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: [] });
}
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));
@@ -115,17 +131,40 @@ type Network = {
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 createNetwork(
page: Page,
name: string,
description = "",
): Promise<Network> {
return apiPost<Network>(page, "/networks", { name, description });
}
export async function deleteNetworkById(page: Page, networkId: string) {
await apiDelete(page, `/networks/${networkId}`);
}
/** Delete all networks matching a prefix. */
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,
});
}
export async function deleteNetworksByPrefix(page: Page, prefix: string) {
const networks = await listNetworks(page);
const toDelete = networks.filter((n) => n.name.startsWith(prefix));
@@ -143,17 +182,40 @@ type Policy = {
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. */
// 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],
},
],
});
}
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(
@@ -164,7 +226,6 @@ 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),
@@ -190,17 +251,14 @@ 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));
@@ -216,17 +274,31 @@ type SetupKey = {
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. */
/** 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,
});
}
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));
@@ -242,17 +314,14 @@ 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));
@@ -269,17 +338,14 @@ 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) {
@@ -287,7 +353,6 @@ 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);
@@ -303,17 +368,14 @@ 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));
@@ -329,17 +391,14 @@ 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));
@@ -357,20 +416,13 @@ 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");
}
/**
* 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.
*/
// The test proxy containers register asynchronously after `test:setup` returns.
export async function waitForProxyClustersOnline(
page: Page,
addresses: string[],
@@ -379,9 +431,6 @@ 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(
@@ -415,17 +464,14 @@ 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);
@@ -434,9 +480,7 @@ export async function deleteUserByEmail(page: Page, email: string) {
}
}
// ---------------------------------------------------------------------------
// Agent Network
// ---------------------------------------------------------------------------
// ── Agent Network ──────────────────────────────────────────────────────
type AgentNetworkCatalogProvider = {
id: string;
@@ -449,7 +493,6 @@ type AgentNetworkProvider = {
provider_id: string;
};
/** List the Agent Network provider catalog (server-defined provider types). */
export async function listAgentNetworkCatalog(
page: Page,
): Promise<AgentNetworkCatalogProvider[]> {
@@ -459,19 +502,16 @@ 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,
@@ -483,3 +523,18 @@ 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
);
}
+9 -1
View File
@@ -117,7 +117,15 @@ export async function loginToApp(
* Use this instead of page.goto() for in-app navigation after loginToApp().
*/
export async function navigateTo(page: Page, path: string) {
await page.goto(path, { waitUntil: "domcontentloaded" });
// 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" });
}
const modal = page.getByTestId("setup-netbird-modal");
try {
await modal.waitFor({ state: "visible", timeout: 3_000 });
+235
View File
@@ -0,0 +1,235 @@
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
@@ -0,0 +1,94 @@
// 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);
}
+22 -24
View File
@@ -5,16 +5,8 @@ export function generateRandomName(prefix?: string): string {
}
/**
* 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.
* 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.
*/
export async function waitForApiCalls<T>(
page: Page,
@@ -49,8 +41,6 @@ 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) {
@@ -66,11 +56,6 @@ 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,
@@ -85,19 +70,32 @@ export async function applyRadioTableFilter(
}
/**
* 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.
* 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.
*/
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());
});
}
+40 -2
View File
@@ -10,7 +10,10 @@
* 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.
* 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.
*
* The Agent Network menu is deployment-gated; the test build honors the
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
@@ -22,6 +25,7 @@ import { generateRandomName } from "../helpers/utils";
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
supportsAgentNetworkSettingsBootstrap,
} from "../helpers/api";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
@@ -62,6 +66,11 @@ 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);
@@ -110,11 +119,40 @@ 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 });
expect([200, 201]).toContain((await createResponse).status());
const created = await Promise.race([createResponse, bootstrapRejected]);
expect([200, 201]).toContain(created.status());
// Row lands in the providers table.
await expect(page.getByText(providerName).first()).toBeVisible();
@@ -0,0 +1,145 @@
/**
* 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
@@ -0,0 +1,88 @@
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);
}
});
});
@@ -0,0 +1,321 @@
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(() => {});
}
}
});
});
@@ -0,0 +1,154 @@
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 293 files, more files were not shown because too many files have changed in this diff. Show more