mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b077d096 | ||
|
|
f7c0cc2a5d | ||
|
|
4f905a4368 | ||
|
|
5cf8995c65 | ||
|
|
ca16b4868b | ||
|
|
d3ff3bafca | ||
|
|
4ca8cc09a4 | ||
|
|
9e6d512ed1 | ||
|
|
7b60d87652 | ||
|
|
d72f417ff3 | ||
|
|
3be6dda6a7 | ||
|
|
98ae943bbc | ||
|
|
99f86b6421 | ||
|
|
0ceda40180 | ||
|
|
f4cf7458be | ||
|
|
9e1c29b769 | ||
|
|
fb05f2c172 | ||
|
|
6cf828ce8a | ||
|
|
83cb07a44c | ||
|
|
bbfa2d3a79 | ||
|
|
a92816b9b8 | ||
|
|
0caf754897 | ||
|
|
49582fff74 | ||
|
|
af7745a03b | ||
|
|
19d82cc2ab | ||
|
|
b66cc4ccb7 | ||
|
|
1d408f2e7e | ||
|
|
9fab9d7a83 | ||
|
|
6ca91287fc | ||
|
|
5a5e04ad35 | ||
|
|
598ecb196d | ||
|
|
7c8352822d | ||
|
|
e4f5046ff2 | ||
|
|
d5f61efc1a | ||
|
|
3503141c56 | ||
|
|
a27e4654cf | ||
|
|
b9569f48d5 | ||
|
|
9694bb753d | ||
|
|
377d239a01 | ||
|
|
a266fff89d | ||
|
|
8ed69f21c5 | ||
|
|
58d1035b16 | ||
|
|
991234785c | ||
|
|
f2ad889fdc | ||
|
|
1f219c4ffc | ||
|
|
87b3a08adc | ||
|
|
cfd0037cef | ||
|
|
ac7d578bd7 | ||
|
|
8d41de995c |
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "*.ts"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "*.js"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
@@ -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
|
||||
@@ -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}."
|
||||
@@ -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') != ''
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Sync deploy templates to S3
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "templates/reverse-proxy/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ secrets.DEPLOY_TEMPLATES_ROLE_ARN }}
|
||||
aws-region: eu-central-1
|
||||
|
||||
- name: Validate CloudFormation template
|
||||
run: |
|
||||
aws cloudformation validate-template \
|
||||
--template-body file://templates/reverse-proxy/netbird-proxy-cfn.yaml
|
||||
|
||||
- name: Upload CloudFormation template
|
||||
run: |
|
||||
aws s3 cp templates/reverse-proxy/netbird-proxy-cfn.yaml \
|
||||
s3://netbird-deploy-templates/templates/netbird-proxy-cfn.yaml
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Test cloud deploy config
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "docker/init_react_envs.sh"
|
||||
- "templates/reverse-proxy/**"
|
||||
- "src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx"
|
||||
- ".github/workflows/test-cloud-deploy.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
csp-connect-src:
|
||||
name: CSP allows cloud provider APIs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install nginx
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nginx gettext-base
|
||||
|
||||
- name: Prepare nginx layout expected by the init script
|
||||
run: |
|
||||
sudo mkdir -p /etc/nginx/http.d /usr/share/nginx/html
|
||||
echo 'add_header Content-Security-Policy "placeholder" always;' | sudo tee /etc/nginx/http.d/default.conf
|
||||
sudo touch /usr/share/nginx/html/OidcTrustedDomains.js.tmpl
|
||||
sudo systemctl start nginx
|
||||
|
||||
- name: Run init script with production-like env
|
||||
env:
|
||||
AUTH_AUTHORITY: https://auth.example.com
|
||||
AUTH_CLIENT_ID: test-client
|
||||
AUTH_AUDIENCE: test-audience
|
||||
AUTH_SUPPORTED_SCOPES: openid profile email
|
||||
USE_AUTH0: "false"
|
||||
NETBIRD_MGMT_API_ENDPOINT: https://api.example.com
|
||||
run: sudo -E bash docker/init_react_envs.sh
|
||||
|
||||
- name: Assert provider APIs are in connect-src
|
||||
run: |
|
||||
CSP=$(grep -o 'Content-Security-Policy "[^"]*"' /etc/nginx/http.d/default.conf)
|
||||
echo "Generated CSP: $CSP"
|
||||
CONNECT_SRC=$(echo "$CSP" | sed 's/.*connect-src \([^;]*\);.*/\1/')
|
||||
echo "connect-src: $CONNECT_SRC"
|
||||
status=0
|
||||
for origin in https://api.hetzner.cloud https://api.digitalocean.com; do
|
||||
if echo "$CONNECT_SRC" | grep -qF "$origin"; then
|
||||
echo "OK: $origin"
|
||||
else
|
||||
echo "MISSING in connect-src: $origin"
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit $status
|
||||
|
||||
cfn-template-url:
|
||||
name: CloudFormation template URL is live
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract template URL from component and check it
|
||||
run: |
|
||||
URL=$(grep -o 'https://[^"]*netbird-proxy-cfn\.yaml' src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx)
|
||||
echo "CFN_TEMPLATE_URL: $URL"
|
||||
test -n "$URL"
|
||||
# Non-blocking: the template is published by sync-deploy-templates on
|
||||
# the default branch, so it may not exist yet for a PR that only edits
|
||||
# the URL. Warn instead of failing the check.
|
||||
if curl -sfI "$URL" >/dev/null; then
|
||||
echo "OK: template URL is live"
|
||||
else
|
||||
echo "::warning::CloudFormation template URL is not reachable yet: $URL"
|
||||
fi
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
[
|
||||
{
|
||||
"tag": "Preview",
|
||||
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
|
||||
"link": "https://github.com/netbirdio/netbird/discussions/6483",
|
||||
"tag": "New",
|
||||
"text": "The new NetBird desktop app is here - now available in the v0.75 release.",
|
||||
"link": "https://netbird.io/knowledge-hub/netbird-v0-75-new-desktop-app",
|
||||
"linkText": "Learn more",
|
||||
"variant": "important",
|
||||
"isExternal": true,
|
||||
|
||||
+1
-1
@@ -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 && \
|
||||
|
||||
@@ -80,7 +80,7 @@ echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
FIRST_PARTY_CSP="pkgs.netbird.io"
|
||||
FIRST_PARTY_CSP_CONNECT_SRC="wss://*.netbird.io"
|
||||
THIRD_PARTY_CSP="*.licdn.com *.linkedin.com *.vector.co *.sibforms.com *.hotjar.com *.hotjar.io *.redditstatic.com pixel-config.reddit.com *.clarity.ms c.bing.com *.microsoft.com googleads.g.doubleclick.net pagead2.googlesyndication.com www.google.com www.googleadservices.com *.google-analytics.com *.googletagmanager.com analytics.google.com *.hubapi.com *.hs-banner.com *.hubspot.com *.hubspot.net js.hs-analytics.com *.hsforms.net *.hscollectedforms.net *.hs-analytics.net *.hsforms.com track.hubspot.com *.hsadspixel.net static.hsappstatic.net"
|
||||
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com"
|
||||
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com https://api.hetzner.cloud https://api.digitalocean.com"
|
||||
THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com"
|
||||
|
||||
CSP_DOMAINS=""
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -9,4 +9,6 @@ proxy-no-ports.env
|
||||
proxy-certs/
|
||||
proxy-certs-no-ports/
|
||||
docker-compose.yml
|
||||
/machinekey
|
||||
/machinekey
|
||||
geo-cache/
|
||||
geo-dump/
|
||||
@@ -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
|
||||
|
||||
+171
-66
@@ -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);
|
||||
@@ -433,3 +479,62 @@ export async function deleteUserByEmail(page: Page, email: string) {
|
||||
await deleteUserById(page, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent Network ──────────────────────────────────────────────────────
|
||||
|
||||
type AgentNetworkCatalogProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type AgentNetworkProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider_id: string;
|
||||
};
|
||||
|
||||
export async function listAgentNetworkCatalog(
|
||||
page: Page,
|
||||
): Promise<AgentNetworkCatalogProvider[]> {
|
||||
return apiGet<AgentNetworkCatalogProvider[]>(
|
||||
page,
|
||||
"/agent-network/catalog/providers",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listAgentNetworkProviders(
|
||||
page: Page,
|
||||
): Promise<AgentNetworkProvider[]> {
|
||||
return apiGet<AgentNetworkProvider[]>(page, "/agent-network/providers");
|
||||
}
|
||||
|
||||
export async function deleteAgentNetworkProviderById(page: Page, id: string) {
|
||||
await apiDelete(page, `/agent-network/providers/${id}`);
|
||||
}
|
||||
|
||||
export async function deleteAgentNetworkProvidersByPrefix(
|
||||
page: Page,
|
||||
prefix: string,
|
||||
) {
|
||||
const providers = await listAgentNetworkProviders(page);
|
||||
for (const p of providers) {
|
||||
if (p.name.startsWith(prefix)) {
|
||||
await deleteAgentNetworkProviderById(page, p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
+26
-5
@@ -20,7 +20,11 @@ const credentials: Record<TestUser, { username: string; password: string }> = {
|
||||
/**
|
||||
* Navigate to the app, authenticate via Zitadel, and wait for the app to load.
|
||||
*/
|
||||
export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
export async function loginToApp(
|
||||
page: Page,
|
||||
user: TestUser = "owner",
|
||||
opts: { expectOnboarding?: boolean } = {},
|
||||
) {
|
||||
const { username, password } = credentials[user];
|
||||
|
||||
await page.goto("/");
|
||||
@@ -39,7 +43,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
const which = await Promise.race([
|
||||
appReady.waitFor({ timeout: 20_000 }).then(() => "app" as const),
|
||||
setupModal.waitFor({ timeout: 20_000 }).then(() => "modal" as const),
|
||||
approvalPending.waitFor({ timeout: 20_000 }).then(() => "approval" as const),
|
||||
approvalPending
|
||||
.waitFor({ timeout: 20_000 })
|
||||
.then(() => "approval" as const),
|
||||
onboarding.waitFor({ timeout: 20_000 }).then(() => "onboarding" as const),
|
||||
selectAccount.waitFor({ timeout: 20_000 }).then(() => "select" as const),
|
||||
loginInput.waitFor({ timeout: 20_000 }).then(() => "login" as const),
|
||||
@@ -51,6 +57,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
}
|
||||
|
||||
if (which === "modal") {
|
||||
// Onboarding tests render a full-screen overlay over the setup modal;
|
||||
// clicking modal-close would be pointer-intercepted, so leave it be.
|
||||
if (opts.expectOnboarding) return;
|
||||
await setupModal.getByTestId("modal-close").click();
|
||||
await expect(setupModal).not.toBeVisible();
|
||||
return;
|
||||
@@ -89,8 +98,12 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
onboarding.waitFor({ timeout: 15_000 }),
|
||||
]);
|
||||
|
||||
// Dismiss setup modal if present
|
||||
if (await setupModal.isVisible().catch(() => false)) {
|
||||
// Dismiss setup modal if present, unless an onboarding overlay is expected
|
||||
// on top of it (clicking through it would be pointer-intercepted).
|
||||
if (
|
||||
!opts.expectOnboarding &&
|
||||
(await setupModal.isVisible().catch(() => false))
|
||||
) {
|
||||
await setupModal.getByTestId("modal-close").click();
|
||||
await expect(setupModal).not.toBeVisible();
|
||||
}
|
||||
@@ -104,7 +117,15 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
* 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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Loaded 20 of 350 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user