Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
- name: Install system dependencies
run: |
apt-get update
apt-get install -y build-essential ca-certificates desktop-file-utils curl git \
apt-get install -y build-essential ca-certificates dbus desktop-file-utils curl git \
iproute2 iputils-ping libayatana-appindicator3-dev libgtk-3-dev libprotobuf-dev \
librsvg2-dev libssl-dev libudev-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf procps \
protobuf-compiler webkit2gtk-driver xvfb wireguard-tools xclip xdg-utils
Expand Down Expand Up @@ -106,7 +106,7 @@ jobs:
working-directory: e2e

- name: Run e2e tests
run: xvfb-run -a pnpm test
run: dbus-run-session -- xvfb-run -a pnpm test
working-directory: e2e

- name: Collect logs
Expand Down
4 changes: 2 additions & 2 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Defguard services to be running separately.
- An administrator account whose login does not require MFA. The password is supplied through `e2e/.env`.
- A dedicated deployment with exactly one test network named `e2e`. The connection checks use the first visible connect button.
- Root `defguard-service` running with `/var/run/defguard.socket` available.
- Rust/Cargo with the Tauri CLI, Node.js, `pnpm`, `just`, `WebKitWebDriver`, and `xvfb-run` available.
- Rust/Cargo with the Tauri CLI, Node.js, `pnpm`, `just`, `WebKitWebDriver`, `xvfb-run`, and `dbus-run-session` available.
- `tauri-driver` available at `~/.cargo/bin/tauri-driver`, or at the path set by `TAURI_DRIVER`.

## Configure the test environment
Expand Down Expand Up @@ -58,6 +58,6 @@ To run one spec directly:
```bash
cd e2e
NATIVE_DRIVER="$(command -v WebKitWebDriver)" \
xvfb-run -a pnpm exec wdio run ./wdio.conf.ts \
dbus-run-session -- xvfb-run -a pnpm exec wdio run ./wdio.conf.ts \
--spec=./tests/enrollment.spec.ts
```
47 changes: 47 additions & 0 deletions e2e/helpers/launchConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import fs from "node:fs";
import path from "node:path";

export type LaunchConfig = {
files?: Record<string, string>;
args?: string[];
};

export const launchConfigs: Record<string, LaunchConfig> = {
"deep_link_cold_start.spec.ts": {
files: {
"provisioning.json": JSON.stringify({
enrollment_url: "https://provisioning.example",
enrollment_token: "e2e-provisioning-token",
}),
},
args: [
"defguard://addinstance?token=e2e-cold-start-token&url=https%3A%2F%2Fcold-start.example",
],
},
"provisioning.spec.ts": {
files: {
"provisioning.json": JSON.stringify({
enrollment_url: "https://enroll.example",
enrollment_token: "e2e-provisioning-token",
}),
},
},
};

export const getLaunchConfig = (specs: string[]): LaunchConfig | undefined => {
for (const spec of specs) {
const specName = path.basename(spec);
if (Object.hasOwn(launchConfigs, specName)) return launchConfigs[specName];
}
};

export const writeLaunchFiles = (
dataDir: string,
files: Record<string, string>,
): void => {
const appDataDir = path.join(dataDir, "share", "net.defguard");
fs.mkdirSync(appDataDir, { recursive: true });
for (const [fileName, contents] of Object.entries(files)) {
fs.writeFileSync(path.join(appDataDir, fileName), contents);
}
};
22 changes: 22 additions & 0 deletions e2e/tests/deep_link_cold_start.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { $, browser, expect } from "@wdio/globals";
import { switchToWindowLabel } from "../helpers/windows.js";

const token = "e2e-cold-start-token";
const url = "https://cold-start.example";

describe("cold-start deep link", () => {
it("prioritizes the deep link over provisioning config", async () => {
await switchToWindowLabel("full-view");
await browser.waitUntil(
async () =>
new URL(await browser.getUrl()).pathname === "/full/add/instance",
{ timeout: 30_000, timeoutMsg: "Cold-start deep link was not handled" },
);

const currentUrl = new URL(await browser.getUrl());
expect(currentUrl.searchParams.get("token")).toBe(token);
expect(currentUrl.searchParams.get("url")).toBe(url);
await expect($("#add-instance-view")).toBeDisplayed();
await expect($('[data-testid="field-url"]')).not.toBeDisplayed();
});
});
48 changes: 48 additions & 0 deletions e2e/tests/deep_link_running_app.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { spawnSync } from "node:child_process";
import { $, browser, expect } from "@wdio/globals";
import { switchToWindowLabel } from "../helpers/windows.js";

const token = "e2e-running-app-token";
const url = "https://running.example";

describe("running-app deep link", () => {
it("forwards the link to the running client", async () => {
await switchToWindowLabel("full-view");
await browser.waitUntil(
async () => new URL(await browser.getUrl()).pathname === "/full/add",
{ timeout: 30_000, timeoutMsg: "Full view did not open" },
);
await expect($("#add-page-view")).toBeDisplayed();

const clientBinary = process.env.CLIENT_BINARY;
if (!clientBinary) {
throw new Error("CLIENT_BINARY was not set by the e2e session");
}
if (!process.env.DBUS_SESSION_BUS_ADDRESS) {
throw new Error("The e2e process has no D-Bus session");
}

const secondInstance = spawnSync(
clientBinary,
[`defguard://addinstance?token=${token}&url=${encodeURIComponent(url)}`],
{ env: { ...process.env }, stdio: "ignore", timeout: 15_000 },
);
if (secondInstance.error) throw secondInstance.error;
expect(secondInstance.status).toBe(0);
expect(secondInstance.signal).toBeNull();

await browser.waitUntil(
async () =>
new URL(await browser.getUrl()).pathname === "/full/add/instance",
{
timeout: 15_000,
timeoutMsg: "Running client did not handle the deep link",
},
);
const currentUrl = new URL(await browser.getUrl());
expect(currentUrl.searchParams.get("token")).toBe(token);
expect(currentUrl.searchParams.get("url")).toBe(url);
await expect($("#add-instance-view")).toBeDisplayed();
await expect($('[data-testid="field-url"]')).not.toBeDisplayed();
});
});
73 changes: 73 additions & 0 deletions e2e/tests/provisioning.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import fs from "node:fs";
import path from "node:path";
import { $, browser, expect } from "@wdio/globals";
import { invoke } from "../helpers/client.js";
import { switchToWindowLabel } from "../helpers/windows.js";

const enrollmentToken = "e2e-provisioning-token";
const enrollmentUrl = "https://enroll.example";

describe("provisioning", () => {
it("opens the prefilled instance form from launch config", async () => {
await switchToWindowLabel("full-view");
await browser.waitUntil(
async () =>
new URL(await browser.getUrl()).pathname === "/full/add/instance",
{ timeout: 10_000, timeoutMsg: "Provisioning form did not open" },
);

const currentUrl = new URL(await browser.getUrl());
expect(currentUrl.searchParams.get("token")).toBe(enrollmentToken);
expect(currentUrl.searchParams.get("url")).toBe(enrollmentUrl);
await expect($("#add-instance-view")).toBeDisplayed();
await expect($('[data-testid="field-url"]')).not.toBeDisplayed();
});

it("does not restore provisioning after the saved instance is deleted", async () => {
await switchToWindowLabel("full-view");
const saved = await invoke<{ instance: { id: number } }>(
"save_device_config",
{
privateKey: "e2e-placeholder-private-key",
response: {
device: {
id: 1,
name: "e2e-device",
pubkey: "e2e-placeholder-public-key",
user_id: 1,
created_at: 0,
},
configs: [],
instance: {
id: "00000000-0000-4000-8000-000000000001",
name: "e2e-instance",
url: "https://core.example",
proxy_url: "https://edge.example",
username: "e2e",
},
},
},
);
const dataDir = process.env.E2E_DATA_DIR;
if (!dataDir) throw new Error("E2E_DATA_DIR is required");
expect(
fs.existsSync(
path.join(dataDir, "share", "net.defguard", "provisioning.json"),
),
).toBe(true);

await browser.url("tauri://localhost/full/");
await browser.waitUntil(
async () => new URL(await browser.getUrl()).pathname === "/full/overview",
{ timeout: 10_000, timeoutMsg: "Expected the saved-instance overview" },
);

await invoke("delete_instance", { instanceId: saved.instance.id });
await browser.url("tauri://localhost/full/");
await browser.waitUntil(
async () => new URL(await browser.getUrl()).pathname === "/full/add",
{ timeout: 10_000, timeoutMsg: "Expected the empty-instance add page" },
);
await expect($("#add-instance-view")).not.toBeDisplayed();
});
});
41 changes: 30 additions & 11 deletions e2e/wdio.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type ChildProcess, spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getLaunchConfig, writeLaunchFiles } from "./helpers/launchConfig.js";

const here = import.meta.dirname;

Expand Down Expand Up @@ -87,23 +88,41 @@ export const config: WebdriverIO.Config = {
}
},

beforeSession: () => {
beforeSession: (_config, caps, specs) => {
killLeftoverClients();
cleanupWireguardInterfaces();
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "defguard-e2e-"));
const launchConfig = getLaunchConfig(specs);
const sessionEnv = {
CLIENT_BINARY: clientBinary,
DEFGUARD_CLIENT_WELCOME_SKIP: "1",
E2E_DATA_DIR: dataDir,
XDG_DATA_HOME: path.join(dataDir, "share"),
XDG_CONFIG_HOME: path.join(dataDir, "config"),
XDG_CACHE_HOME: path.join(dataDir, "cache"),
};
Object.assign(process.env, sessionEnv);

if (launchConfig?.files) {
writeLaunchFiles(dataDir, launchConfig.files);
}

if (launchConfig?.args) {
const tauriOptions = caps["tauri:options"] as
| { args?: string[] }
| undefined;
if (!tauriOptions) {
throw new Error(
"Missing tauri:options capability for launch arguments",
);
}
tauriOptions.args = launchConfig.args;
}

tauriDriver = spawn(
tauriDriverBinary,
nativeDriver ? ["--native-driver", nativeDriver] : [],
{
stdio: ["ignore", "inherit", "inherit"],
env: {
...process.env,
DEFGUARD_CLIENT_WELCOME_SKIP: "1",
XDG_DATA_HOME: path.join(dataDir, "share"),
XDG_CONFIG_HOME: path.join(dataDir, "config"),
XDG_CACHE_HOME: path.join(dataDir, "cache"),
},
},
{ stdio: ["ignore", "inherit", "inherit"] },
);
tauriDriver.on("error", (error) => {
console.error("tauri-driver failed to start:", error);
Expand Down
5 changes: 3 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ e2e-test:
test "$(uname -s)" = "Linux" || { echo "Client E2E tests require Linux." >&2; exit 1; }
test -f e2e/.env || { echo "Missing e2e/.env; copy e2e/.env.example and fill it locally." >&2; exit 1; }
test -S /var/run/defguard.socket || { echo "defguard-service is not running at /var/run/defguard.socket." >&2; exit 1; }
test -x "${TAURI_DRIVER:-$HOME/.cargo/bin/tauri-driver}" || { echo "tauri-driver is missing; install it with: cargo install tauri-driver --locked" >&2; exit 1; }
test -x "${TAURI_DRIVER:-$HOME/.cargo/bin/tauri-driver}" || { echo "tauri-driver is missing; set TAURI_DRIVER to its location." >&2; exit 1; }
command -v WebKitWebDriver >/dev/null || { echo "WebKitWebDriver is missing; enter nix develop." >&2; exit 1; }
command -v xvfb-run >/dev/null || { echo "xvfb-run is missing; enter nix develop." >&2; exit 1; }
cd e2e && NATIVE_DRIVER="${NATIVE_DRIVER:-$(command -v WebKitWebDriver)}" xvfb-run -a pnpm test
command -v dbus-run-session >/dev/null || { echo "dbus-run-session is missing; enter nix develop." >&2; exit 1; }
cd e2e && NATIVE_DRIVER="${NATIVE_DRIVER:-$(command -v WebKitWebDriver)}" dbus-run-session -- xvfb-run -a pnpm test

e2e: e2e-build e2e-provision e2e-test
75 changes: 75 additions & 0 deletions new-ui/src/routes/full/-utils/resolveFullIndexRedirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { resolveFullIndexRedirect } from './resolveFullIndexRedirect';

describe('resolveFullIndexRedirect', () => {
it('adds an empty client when no provisioning config exists', () => {
expect(
resolveFullIndexRedirect({
instanceCount: 0,
tunnelCount: 0,
provisioningConfig: null,
}),
).toEqual({ to: '/full/add' });
});

it('opens the prefilled add-instance form for an empty client with config', () => {
expect(
resolveFullIndexRedirect({
instanceCount: 0,
tunnelCount: 0,
provisioningConfig: {
enrollment_url: 'https://enroll.example',
enrollment_token: 'provisioning-token',
},
}),
).toEqual({
to: '/full/add/instance',
search: {
url: 'https://enroll.example',
token: 'provisioning-token',
},
});
});

it('opens the prefilled form even if tunnels exist', () => {
expect(
resolveFullIndexRedirect({
instanceCount: 0,
tunnelCount: 1,
provisioningConfig: {
enrollment_url: 'https://enroll.example',
enrollment_token: 'provisioning-token',
},
}),
).toEqual({
to: '/full/add/instance',
search: {
url: 'https://enroll.example',
token: 'provisioning-token',
},
});
});

it('shows overview when an instance exists despite config', () => {
expect(
resolveFullIndexRedirect({
instanceCount: 1,
tunnelCount: 0,
provisioningConfig: {
enrollment_url: 'https://enroll.example',
enrollment_token: 'provisioning-token',
},
}),
).toEqual({ to: '/full/overview' });
});

it('shows overview for an empty client with tunnels and no config', () => {
expect(
resolveFullIndexRedirect({
instanceCount: 0,
tunnelCount: 1,
provisioningConfig: null,
}),
).toEqual({ to: '/full/overview' });
});
});
Loading
Loading