import { test, expect } from "@playwright/test"; import { start, stop, restart, baseUrl } from "./evcc"; import { expectModalVisible, expectModalHidden } from "./utils"; import { simulatorUrl, startSimulator, stopSimulator } from "./simulator"; test.use({ baseURL: baseUrl() }); const secret = "topsecret"; const templateFlags = [ "--disable-auth", "--template-type", "meter", "--template", "tests/config-device-auth-demo.tpl.yaml", ]; // Build complete redirect URI with callback path function getRedirectUri(url: string): string { return new URL(url).origin + "/providerauth/callback"; } test.beforeEach(async () => { await startSimulator(); await start(undefined, undefined, templateFlags); }); test.afterEach(async () => { await stop(); await stopSimulator(); }); test.describe("config device auth", async () => { test("create grid meter with redirect auth", async ({ page }) => { await page.goto("/#/config"); // verify no grid meter exists yet await expect(page.getByTestId("grid")).toHaveCount(0); await expect(page.getByRole("button", { name: "Add grid meter" })).toBeVisible(); // create a grid meter with auth await page.getByRole("button", { name: "Add grid meter" }).click(); const meterModal = page.getByTestId("meter-modal"); await expectModalVisible(meterModal); await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); // step 1: auth view await expect(meterModal.getByLabel("Server")).toBeVisible(); await expect(meterModal.getByLabel("Redirect URI")).toBeVisible(); await expect(meterModal.getByLabel("Authentication Method")).toBeVisible(); await expect(meterModal.getByLabel("Power")).not.toBeVisible(); await expect(meterModal.getByRole("button", { name: "Validate & save" })).not.toBeVisible(); await expect(meterModal.getByRole("button", { name: "Save" })).not.toBeVisible(); await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); // Get the login link and remove target="_blank" to keep it in same page const loginLink = meterModal.getByRole("link", { name: "Connect to localhost" }); await expect(loginLink).toBeVisible(); await loginLink.evaluate((el) => el.removeAttribute("target")); await loginLink.click(); // Wait for navigation to mock login page await page.waitForLoadState("networkidle"); // Click login button on mock page - use evaluate to ensure JS executes const loginButton = page.getByRole("button", { name: "Login Successfully" }); await expect(loginButton).toBeVisible(); await loginButton.evaluate((btn: HTMLButtonElement) => btn.click()); // Wait for redirect back to config page await page.waitForURL(/.*\/#\/config.*/); // Verify success banner appears const successBanner = page.getByTestId("auth-success-banner"); await expect(successBanner).toBeVisible(); await expect(successBanner).toContainText("Authorization Successful"); await expect(successBanner).toContainText("Demo Auth is now connected and ready to use"); // modal reopens automatically after auth; continue configuration await expectModalVisible(meterModal); await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); // step 2: show regular device form - auth is complete, fill in server details again await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); await meterModal.getByLabel("Secret").fill(secret); // Even though auth is already done, still need to click prepare connection to proceed to device fields await meterModal.getByRole("button", { name: "Prepare connection" }).click(); await page.waitForTimeout(500); // Now the Power field should be visible since auth is already complete await expect(meterModal.getByLabel("Power")).toBeVisible(); await meterModal.getByLabel("Power").fill("5000"); await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); await meterModal.getByRole("link", { name: "validate" }).click(); await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW"); await meterModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(meterModal); // verify meter creation await expect(page.getByTestId("grid")).toBeVisible(); await expect(page.getByTestId("grid")).toContainText("Grid meter"); await expect(page.getByTestId("grid")).toContainText(["Power", "5.0 kW"].join("")); // re-open meter for editing await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); await expectModalVisible(meterModal); await expect(meterModal.getByLabel("Server")).toHaveValue(simulatorUrl()); await expect(meterModal.getByLabel("Authentication Method")).toHaveValue("redirect"); await expect(meterModal.getByLabel("Secret")).toHaveValue("***"); await expect(meterModal.getByLabel("Power")).toHaveValue("5000"); await expect(meterModal.getByRole("button", { name: "Prepare connection" })).not.toBeVisible(); await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); await meterModal.getByRole("button", { name: "Close" }).click(); await expectModalHidden(meterModal); // restart evcc (demo auth doesn't persist) await restart(undefined, templateFlags); await page.reload(); // re-open meter for editing after restart, auth status has to be reestablished await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); await expectModalVisible(meterModal); await expect(meterModal.getByLabel("Server")).toHaveValue(simulatorUrl()); await expect(meterModal.getByLabel("Authentication Method")).toHaveValue("redirect"); await expect(meterModal.getByLabel("Power")).not.toBeVisible(); // note: prepare connection step is auto-executed, since all required fields are already present await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).toBeVisible(); await expect(meterModal.getByRole("button", { name: "Validate & save" })).not.toBeVisible(); }); test("redirect auth restores the open modal on return", async ({ page }) => { await page.goto("/#/config"); // open the grid meter modal and prepare auth await page.getByRole("button", { name: "Add grid meter" }).click(); const meterModal = page.getByTestId("meter-modal"); await expectModalVisible(meterModal); await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); // jump to provider in same page const loginLink = meterModal.getByRole("link", { name: "Connect to localhost" }); await expect(loginLink).toBeVisible(); await loginLink.evaluate((el) => el.removeAttribute("target")); await loginLink.click(); await page.waitForLoadState("networkidle"); const loginButton = page.getByRole("button", { name: "Login Successfully" }); await expect(loginButton).toBeVisible(); await loginButton.evaluate((btn: HTMLButtonElement) => btn.click()); // callback restores the modal query alongside the completion marker await page.waitForURL(/.*\/#\/config.*callbackCompleted.*/); expect(page.url()).toContain("meter[type:grid]"); // modal reopens automatically and success banner is shown await expectModalVisible(meterModal); await expect(page.getByTestId("auth-success-banner")).toBeVisible(); }); test("advanced auth field is hidden until expanded", async ({ page }) => { await page.goto("/#/config"); await page.getByRole("button", { name: "Add grid meter" }).click(); const meterModal = page.getByTestId("meter-modal"); await expectModalVisible(meterModal); await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); // normal auth fields are shown, advanced "Scope" is collapsed await expect(meterModal.getByLabel("Server")).toBeVisible(); await expect(meterModal.getByLabel("Scope")).not.toBeVisible(); // expand advanced section manually await meterModal.getByRole("button", { name: "Show advanced settings" }).click(); await expect(meterModal.getByLabel("Scope")).toBeVisible(); await expect(meterModal.getByLabel("Scope")).toHaveValue("read"); }); test("create grid meter with device-code auth", async ({ page }) => { await page.goto("/#/config"); // create a grid meter with device-code auth await page.getByRole("button", { name: "Add grid meter" }).click(); const meterModal = page.getByTestId("meter-modal"); await expectModalVisible(meterModal); await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); // select device-code method await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("device-code"); await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); // verify device code is displayed await expect(meterModal.getByLabel("Authentication Code")).toHaveValue("12AB345"); await expect(meterModal).toContainText("Valid for"); await expect(meterModal).toContainText("Copy this code"); await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).toBeVisible(); }); test("error server shows auth error", async ({ page }) => { await page.goto("/#/config"); await page.getByRole("button", { name: "Add grid meter" }).click(); const meterModal = page.getByTestId("meter-modal"); await expectModalVisible(meterModal); await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); await meterModal.getByLabel("Server").fill("invalid-url-without-scheme"); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); await expect(meterModal).toContainText("server must start with http:// or https://"); await expect(meterModal.getByRole("button", { name: "Prepare connection" })).toBeVisible(); await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).not.toBeVisible(); await expect(meterModal.getByLabel("Authentication Code")).not.toBeVisible(); await expect(meterModal.getByLabel("Power")).not.toBeVisible(); // clear error on input change await meterModal.getByLabel("Server").fill(simulatorUrl()); await expect(meterModal).not.toContainText("server must start with http:// or https://"); // test invalid redirect URI await meterModal.getByLabel("Redirect URI").fill("invalid-redirect"); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); await expect(meterModal).toContainText("redirectUri must start with http:// or https://"); await expect(meterModal.getByRole("button", { name: "Prepare connection" })).toBeVisible(); await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).not.toBeVisible(); await expect(meterModal.getByLabel("Authentication Code")).not.toBeVisible(); await expect(meterModal.getByLabel("Power")).not.toBeVisible(); // clear error on input change await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await expect(meterModal).not.toContainText("redirectUri must start with http:// or https://"); }); test("user denies authorization shows error banner", async ({ page }) => { await page.goto("/#/config"); // create a grid meter with auth await page.getByRole("button", { name: "Add grid meter" }).click(); const meterModal = page.getByTestId("meter-modal"); await expectModalVisible(meterModal); await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); // Get the login link and remove target="_blank" to keep it in same page const loginLink = meterModal.getByRole("link", { name: "Connect to localhost" }); await expect(loginLink).toBeVisible(); await loginLink.evaluate((el) => el.removeAttribute("target")); await loginLink.click(); // Wait for navigation to mock login page await page.waitForLoadState("networkidle"); // Click deny button on mock page const denyButton = page.getByRole("button", { name: "Deny Access" }); await expect(denyButton).toBeVisible(); await denyButton.evaluate((btn: HTMLButtonElement) => btn.click()); // Wait for redirect back to config page (first goes through callback, then to config) await page.waitForURL(/.*\/#\/config.*callbackError.*/); // Wait for the error banner to appear const errorBanner = page.getByTestId("auth-error-banner"); await expect(errorBanner).toBeVisible(); await expect(errorBanner).toContainText("access_denied: User denied authorization"); }); test("authorization card connect and disconnect flow", async ({ page }) => { await page.goto("/#/config"); // create a grid meter with auth and prepare connection await page.getByRole("button", { name: "Add grid meter" }).click(); const meterModal = page.getByTestId("meter-modal"); await expectModalVisible(meterModal); await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); await meterModal.getByLabel("Server").fill(simulatorUrl()); await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); await meterModal.getByLabel("Authentication Method").selectOption("redirect"); await meterModal.getByLabel("Secret").fill(secret); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); // verify connection link is ready but close modal instead of clicking it await expect(meterModal.getByRole("link", { name: "Connect to localhost" })).toBeVisible(); await meterModal.getByRole("button", { name: "Close" }).click(); await expectModalHidden(meterModal); // check that authorization card appeared with Demo Auth entry const authCard = page.getByTestId("auth-providers"); await expect(authCard).toBeVisible(); await expect(authCard).toContainText("Demo Auth"); const connectButton = authCard.getByRole("button", { name: "Connect" }); await expect(connectButton).toBeVisible(); // click connect button and verify auth provider modal opens await connectButton.click(); const authModal = page.getByTestId("auth-provider-modal"); await expectModalVisible(authModal); const loginLink = authModal.getByRole("link", { name: "Connect to localhost" }); await expect(loginLink).toBeVisible(); // complete authentication flow await loginLink.evaluate((el) => el.removeAttribute("target")); await loginLink.click(); // wait for navigation to mock login page await page.waitForLoadState("networkidle"); // click login button on mock page const loginButton = page.getByRole("button", { name: "Login Successfully" }); await expect(loginButton).toBeVisible(); await loginButton.evaluate((btn: HTMLButtonElement) => btn.click()); // wait for redirect back to config page await page.waitForURL(/.*\/#\/config.*/); // verify card still exists and Demo Auth now shows disconnect button await expect(authCard).toBeVisible(); await expect(authCard).toContainText("Demo Auth"); const disconnectButton = authCard.getByRole("button", { name: "disconnect" }); await expect(disconnectButton).toBeVisible(); // click disconnect and verify modal contents await disconnectButton.click(); await expectModalVisible(authModal); await expect(authModal).toContainText("Demo Auth"); const confirmDisconnectButton = authModal.getByRole("button", { name: "disconnect" }); await expect(confirmDisconnectButton).toBeVisible(); // confirm disconnect await confirmDisconnectButton.click(); await expectModalHidden(authModal); // verify card still exists and Demo Auth shows connect button again await expect(authCard).toBeVisible(); await expect(authCard).toContainText("Demo Auth"); await expect(authCard.getByRole("button", { name: "Connect" })).toBeVisible(); }); });