Brings the unified dashboard into the open-source repo. Premium features ship in the open code, gated at runtime via NETBIRD_CLOUD and NETBIRD_LICENSED, with upgrade prompts for unlicensed self-hosted deployments. Adds the cloud-only feature areas (billing, integrations, MSP, traffic events, notifications) and the Playwright e2e suite.
276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
import { test, expect } from "../helpers/fixtures";
|
|
import { navigateTo } from "../helpers/auth";
|
|
import { generateRandomName } from "../helpers/utils";
|
|
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
|
|
import { gotoReverseProxyPage, selectProxyDomain, CUSTOM_PORTS_DOMAIN } from "../helpers/reverse-proxy-l4";
|
|
|
|
let createdNetwork = "";
|
|
let createdResource = "";
|
|
let createdSubdomain = "";
|
|
|
|
test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => {
|
|
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
|
|
// Clean up leftovers from previous runs (unique prefix per protocol)
|
|
await deleteServicesByPrefix(page, "https-svc-");
|
|
await deleteNetworksByPrefix(page, "rp-https-net-");
|
|
await navigateTo(page, "/networks");
|
|
const name = generateRandomName("rp-https-net-");
|
|
createdNetwork = name;
|
|
await page.getByTestId("add-network").click();
|
|
await page.getByTestId("network-name-input").fill(name);
|
|
await page.getByTestId("submit-network").click();
|
|
await page.getByTestId("confirmation.confirm").click({ force: true });
|
|
|
|
// Add resource
|
|
const resName = generateRandomName("rp-resource-");
|
|
createdResource = resName;
|
|
await page.getByTestId("resource-name-input").fill(resName);
|
|
await page.getByTestId("resource-address-input").fill("10.99.99.10");
|
|
await page.getByTestId("resource-continue").click();
|
|
|
|
const responsePromise = page.waitForResponse(
|
|
(resp) =>
|
|
resp.url().includes("/api/networks/") &&
|
|
resp.url().includes("/resources") &&
|
|
resp.request().method() === "POST",
|
|
{ timeout: 30_000 },
|
|
);
|
|
await page.getByTestId("submit-resource").click();
|
|
await page.getByTestId("confirmation.confirm").click({ force: true });
|
|
await responsePromise;
|
|
await page.getByTestId("confirmation.cancel").click({ force: true });
|
|
});
|
|
|
|
test("Should create an HTTPS reverse proxy service with full configuration", async ({
|
|
dashboardAsOwner: page,
|
|
}) => {
|
|
test.setTimeout(60_000);
|
|
await gotoReverseProxyPage(page, "/reverse-proxy/services");
|
|
const subdomain = generateRandomName("https-svc-");
|
|
createdSubdomain = subdomain;
|
|
|
|
await page.getByTestId("add-service").first().click();
|
|
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Step 1: Service
|
|
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
|
|
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
|
|
|
|
// Add 2 targets: http with options, https with port
|
|
await addTarget(page, {
|
|
resourceName: createdResource,
|
|
protocol: "http",
|
|
timeout: "10s",
|
|
customHeader: { name: "X-Custom-Header", value: "custom-value" },
|
|
});
|
|
await addTarget(page, {
|
|
resourceName: createdResource,
|
|
location: "/secure",
|
|
protocol: "https",
|
|
port: 4433,
|
|
});
|
|
|
|
const targetsSection = page.getByText("HTTPS Targets").locator("..");
|
|
await expect(targetsSection.locator("table tbody tr")).toHaveCount(2);
|
|
|
|
await page.getByTestId("proxy-continue").click();
|
|
|
|
// Step 2: Authentication
|
|
await page.getByTestId("auth-sso-card").click();
|
|
await page.getByTestId("submit-sso").click();
|
|
|
|
await page.getByTestId("auth-password-card").click();
|
|
await page.getByTestId("password-input").fill("super-secret-pass");
|
|
await page.getByTestId("submit-password").click();
|
|
|
|
await page.getByTestId("auth-pin-card").click();
|
|
const pinInputs = page.locator('input[inputmode="numeric"][maxlength="1"]');
|
|
for (let i = 0; i < 6; i++) {
|
|
await pinInputs.nth(i).fill(String(i + 1), { force: true });
|
|
}
|
|
await page.getByTestId("submit-pin").click();
|
|
|
|
await page.getByTestId("auth-header-card").click();
|
|
await page.getByTestId("header-type-select").click();
|
|
await page.locator("[cmdk-list]").getByText("Basic Auth").click({ force: true });
|
|
await page.getByTestId("header-basic-username").fill("admin");
|
|
await page.getByTestId("header-basic-password").fill("admin-pass");
|
|
await page.getByTestId("submit-headers").click();
|
|
|
|
await page.getByTestId("proxy-continue").click();
|
|
|
|
// Step 3: Access Control
|
|
await page.getByTestId("add-access-rule").click();
|
|
await page.getByTestId("access-rule-0").getByText("Select country...").click();
|
|
await page.getByTestId("select-dropdown-search").fill("Germany");
|
|
await page.getByText("Germany (DE)").click({ force: true });
|
|
|
|
await page.getByTestId("add-access-rule").click();
|
|
await page.getByTestId("access-rule-1").getByTestId("access-rule-action").click();
|
|
await page.getByText("Block Only").click({ force: true });
|
|
await page.getByTestId("access-rule-1").getByTestId("access-rule-type").click();
|
|
await page.locator('[role="option"]').filter({ hasText: "IP Address" }).click({ force: true });
|
|
const ipInput = page.getByTestId("access-rule-1").getByTestId("access-rule-value");
|
|
await expect(ipInput).toBeVisible();
|
|
await ipInput.fill("85.203.15.42");
|
|
|
|
await page.getByTestId("proxy-continue").click();
|
|
|
|
// Step 4: Advanced Settings
|
|
await page.getByTestId("toggle-pass-host-header").click();
|
|
await page.getByTestId("toggle-rewrite-redirects").click();
|
|
await page.getByTestId("submit-service").click();
|
|
|
|
await expect(page.locator("tr").filter({ hasText: subdomain })).toBeVisible({ timeout: 30_000 });
|
|
});
|
|
|
|
test("Should edit the service, remove auth and rules, then delete", async ({
|
|
dashboardAsOwner: page,
|
|
}) => {
|
|
await resetServiceFilters(page);
|
|
await page.locator("tr").filter({ hasText: createdSubdomain }).getByTestId("service-actions").click({ force: true });
|
|
await page.getByTestId("edit-service").click({ force: true });
|
|
|
|
// Edit first target
|
|
const targetsSection = page.getByText("HTTPS Targets").locator("..");
|
|
await targetsSection.locator("table tbody tr").first().click({ force: true });
|
|
await page.getByTestId("target-location-input").fill("/new-location");
|
|
await page.getByTestId("submit-target").click();
|
|
|
|
// Remove second target
|
|
await targetsSection.locator("table tbody tr").filter({ hasText: "/secure" }).getByTestId("target-row-actions").click();
|
|
await page.getByTestId("remove-target").click();
|
|
await expect(targetsSection.locator("table tbody tr")).toHaveCount(1);
|
|
|
|
// Remove all auth methods — click Edit on each card, then Remove in the modal
|
|
await page.getByTestId("proxy-tab-auth").click({ force: true });
|
|
await removeAuthMethod(page, "auth-sso-card", "remove-sso");
|
|
await removeAuthMethod(page, "auth-password-card", "remove-password");
|
|
await removeAuthMethod(page, "auth-pin-card", "remove-pin");
|
|
await removeAuthMethod(page, "auth-header-card", "remove-headers");
|
|
|
|
// Remove access control rules
|
|
await page.getByTestId("proxy-tab-access-control").click({ force: true });
|
|
await page.getByTestId("remove-access-rule").last().click({ force: true });
|
|
await page.getByTestId("remove-access-rule").first().click({ force: true });
|
|
|
|
// Toggle advanced settings back
|
|
await page.getByTestId("proxy-tab-settings").click({ force: true });
|
|
await page.getByTestId("toggle-pass-host-header").click({ force: true });
|
|
await page.getByTestId("toggle-rewrite-redirects").click({ force: true });
|
|
|
|
// Save and wait for API response
|
|
const saveResponse = page.waitForResponse(
|
|
(resp) =>
|
|
resp.url().includes("/api/reverse-proxies/services") &&
|
|
resp.request().method() === "PUT",
|
|
{ timeout: 15_000 },
|
|
);
|
|
await page.getByTestId("proxy-save").click();
|
|
const confirmBtn = page.getByTestId("confirmation.confirm");
|
|
if (await confirmBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
|
await confirmBtn.click({ force: true });
|
|
}
|
|
await saveResponse;
|
|
|
|
// Verify no auth / no access rules: both cells now show a "0" count badge.
|
|
await resetServiceFilters(page);
|
|
const row = page.locator("tr").filter({ hasText: createdSubdomain });
|
|
await expect(row.locator("[data-auth-cell]")).toContainText("0", {
|
|
timeout: 15_000,
|
|
});
|
|
await expect(
|
|
row.locator("[data-access-control-cell]"),
|
|
).toContainText("0", { timeout: 15_000 });
|
|
|
|
// Delete the service
|
|
await row.getByTestId("service-actions").click({ force: true });
|
|
await page.getByTestId("delete-service").click({ force: true });
|
|
await page.getByTestId("confirmation.confirm").click({ force: true });
|
|
await expect(row).not.toBeVisible();
|
|
});
|
|
|
|
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
|
|
await deleteNetworksByPrefix(page, createdNetwork);
|
|
});
|
|
});
|
|
|
|
async function resetServiceFilters(page: import("@playwright/test").Page) {
|
|
const resetBtn = page.getByTestId("reset-filters-and-search");
|
|
if (await resetBtn.isVisible().catch(() => false)) {
|
|
await resetBtn.click();
|
|
}
|
|
}
|
|
|
|
type AddTargetOptions = {
|
|
resourceName: string;
|
|
location?: string;
|
|
protocol?: "http" | "https";
|
|
port?: number;
|
|
timeout?: string;
|
|
customHeader?: { name: string; value: string };
|
|
};
|
|
|
|
async function addTarget(page: import("@playwright/test").Page, opts: AddTargetOptions) {
|
|
await page.getByTestId("add-target").scrollIntoViewIfNeeded();
|
|
await page.getByTestId("add-target").click();
|
|
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.getByTestId("group-selector-dropdown").click();
|
|
await page.locator('[role="tab"]').filter({ hasText: "Resources" }).click({ force: true });
|
|
const search = page.getByTestId("group-selector-dropdown-search");
|
|
await expect(search).toBeVisible({ timeout: 5_000 });
|
|
await search.fill(opts.resourceName);
|
|
await page.getByText(opts.resourceName).click({ force: true, timeout: 15_000 });
|
|
await expect(page.getByTestId("target-port-input")).toBeVisible({ timeout: 10_000 });
|
|
|
|
if (opts.location) {
|
|
await expect(page.getByTestId("target-location-input")).toBeEnabled({ timeout: 5_000 });
|
|
await page.getByTestId("target-location-input").fill(opts.location);
|
|
}
|
|
|
|
if (opts.protocol === "https") {
|
|
await page.getByTestId("target-protocol-select").click();
|
|
await page.locator("[cmdk-list]").getByText("https://").click({ force: true });
|
|
}
|
|
|
|
if (opts.port !== undefined) {
|
|
await page.getByTestId("target-port-input").fill(String(opts.port));
|
|
} else {
|
|
await page.getByTestId("target-port-input").fill("");
|
|
}
|
|
|
|
if (opts.timeout || opts.customHeader) {
|
|
await page.getByTestId("target-optional-settings").click();
|
|
if (opts.timeout) {
|
|
await page.getByTestId("target-timeout-input").fill(opts.timeout);
|
|
}
|
|
if (opts.customHeader) {
|
|
await page.getByTestId("add-custom-header").click();
|
|
await page.getByTestId("custom-header-name-0").fill(opts.customHeader.name);
|
|
await page.getByTestId("custom-header-value-0").fill(opts.customHeader.value);
|
|
}
|
|
}
|
|
|
|
await page.getByTestId("submit-target").click();
|
|
}
|
|
|
|
async function removeAuthMethod(
|
|
page: import("@playwright/test").Page,
|
|
cardTestId: string,
|
|
removeTestId: string,
|
|
) {
|
|
const card = page.getByTestId(cardTestId);
|
|
const removeBtn = page.getByTestId(removeTestId);
|
|
|
|
// Click the card to open the auth modal
|
|
await card.click();
|
|
await expect(removeBtn).toBeVisible();
|
|
await removeBtn.click();
|
|
|
|
// Wait for the modal to fully close — the remove button must disappear
|
|
// and the "Enabled" badge on the card should also disappear
|
|
await expect(removeBtn).not.toBeVisible();
|
|
await expect(card.getByText("Enabled")).not.toBeVisible();
|
|
}
|