diff --git a/client/e2eTests/protoOS/fixtures/pageFixtures.ts b/client/e2eTests/protoOS/fixtures/pageFixtures.ts index 5e7713407..d9b8af61d 100644 --- a/client/e2eTests/protoOS/fixtures/pageFixtures.ts +++ b/client/e2eTests/protoOS/fixtures/pageFixtures.ts @@ -1,6 +1,7 @@ // NOTE: eslint incorrectly identifies 'use' as react hook /* eslint-disable react-hooks/rules-of-hooks */ import { test as base } from "@playwright/test"; +import { AuthStateHelper } from "../helpers/authStateHelper"; import { CommonSteps } from "../helpers/commonSteps"; import { FirmwareHelper } from "../helpers/firmwareHelper"; import { AuthenticationPage } from "../pages/authentication"; @@ -27,6 +28,7 @@ type PageFixtures = { generalPage: GeneralPage; hardwarePage: HardwarePage; coolingPage: CoolingPage; + authStateHelper: AuthStateHelper; firmwareHelper: FirmwareHelper; commonSteps: CommonSteps; navigationComponent: NavigationComponent; @@ -63,6 +65,9 @@ export const test = base.extend({ coolingPage: async ({ page, isMobile }, use) => { await use(new CoolingPage(page, isMobile)); }, + authStateHelper: async ({ page }, use) => { + await use(new AuthStateHelper(page)); + }, firmwareHelper: async ({ page, request }, use) => { await use(new FirmwareHelper(page, request)); }, diff --git a/client/e2eTests/protoOS/helpers/authStateHelper.ts b/client/e2eTests/protoOS/helpers/authStateHelper.ts new file mode 100644 index 000000000..de72f0793 --- /dev/null +++ b/client/e2eTests/protoOS/helpers/authStateHelper.ts @@ -0,0 +1,93 @@ +import { expect, Page } from "@playwright/test"; + +const FAKE_PROTO_RIG_SERIAL_PREFIX = "PROTO-SIM-"; + +type TestingSystemInfoResponse = { + "system-info": { + cb_sn?: string; + manufacturer?: string; + product_name?: string; + model?: string; + }; +}; + +type TestingAuthState = { + password: string; + defaultPassword: string; + onboarded: boolean; +}; + +export class AuthStateHelper { + private hasValidatedSimulatorTarget = false; + + constructor(private page: Page) {} + + private async ensureAppLoaded() { + if (this.page.url() !== "about:blank") { + return; + } + + await this.page.goto("/"); + } + + private async assertSafeSimulatorTarget() { + await this.ensureAppLoaded(); + + if (this.hasValidatedSimulatorTarget) { + return; + } + + const data = (await this.page.evaluate(async () => { + const response = await fetch("/api/v1/system"); + + if (!response.ok) { + throw new Error(`Failed to load simulator system info: ${response.status} ${response.statusText}`); + } + + return (await response.json()) as TestingSystemInfoResponse; + })) as TestingSystemInfoResponse; + const systemInfo = data["system-info"]; + const serialNumber = systemInfo.cb_sn ?? ""; + + if (!serialNumber.startsWith(FAKE_PROTO_RIG_SERIAL_PREFIX)) { + throw new Error( + `Refusing to mutate auth state on non-simulator target "${serialNumber || "unknown"}" (${systemInfo.manufacturer ?? "unknown"} ${systemInfo.product_name ?? systemInfo.model ?? "unknown"}).`, + ); + } + + this.hasValidatedSimulatorTarget = true; + } + + async setState({ password, defaultPassword, onboarded }: TestingAuthState) { + await this.assertSafeSimulatorTarget(); + + const response = await this.page.evaluate( + async (nextState) => { + const request = await fetch("/api/v1/testing/auth-state", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + password: nextState.password, + default_password: nextState.defaultPassword, + onboarded: nextState.onboarded, + }), + }); + + return { + ok: request.ok, + status: request.status, + statusText: request.statusText, + body: await request.text(), + }; + }, + { password, defaultPassword, onboarded }, + ); + + expect( + response.ok, + `Failed to seed fake-rig auth state: ${response.status} ${response.statusText} ${response.body}`, + ).toBeTruthy(); + } +} diff --git a/client/e2eTests/protoOS/pages/authentication.ts b/client/e2eTests/protoOS/pages/authentication.ts index a03438bbe..04d4c24cd 100644 --- a/client/e2eTests/protoOS/pages/authentication.ts +++ b/client/e2eTests/protoOS/pages/authentication.ts @@ -1,3 +1,23 @@ +import { expect } from "@playwright/test"; import { BasePage } from "./base"; -export class AuthenticationPage extends BasePage {} +export class AuthenticationPage extends BasePage { + async validateOnboardingAuthenticationUrl() { + await expect(this.page).toHaveURL(/.*\/onboarding\/authentication/); + } + + async validateSettingsAuthenticationUrl() { + await expect(this.page).toHaveURL(/.*\/settings\/authentication/); + } + + async validateLoginRequiredModal() { + await this.validateModalIsOpen(); + await this.validateTitleInModal("Login required"); + await this.validateButtonIsVisible("Cancel"); + await expect(this.page.getByTestId("login-button")).toBeVisible(); + } + + async dismissLoginRequiredModal() { + await this.page.getByTestId("modal").getByRole("button", { name: "Cancel" }).click(); + } +} diff --git a/client/e2eTests/protoOS/spec/authRedirects.spec.ts b/client/e2eTests/protoOS/spec/authRedirects.spec.ts new file mode 100644 index 000000000..3b144d617 --- /dev/null +++ b/client/e2eTests/protoOS/spec/authRedirects.spec.ts @@ -0,0 +1,63 @@ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; + +const DEFAULT_PASSWORD = "FactoryPass123!"; + +test.describe("Authentication redirects", () => { + test("redirects protected routes to onboarding authentication while the default password is still active", async ({ + page, + authStateHelper, + authenticationPage, + }) => { + await test.step("Seed the simulator with an active default password", async () => { + await authStateHelper.setState({ + password: DEFAULT_PASSWORD, + defaultPassword: DEFAULT_PASSWORD, + onboarded: true, + }); + }); + + await test.step("Open a protected settings route", async () => { + await page.goto("/settings/general"); + }); + + await test.step("Validate the user is redirected to update the default password", async () => { + await authenticationPage.validateOnboardingAuthenticationUrl(); + await authenticationPage.validateTitle("Update your admin login"); + await authenticationPage.validateTextIsVisible( + "Your miner is still using the factory default password. Change it now to continue setup.", + ); + }); + }); + + test("routes dismissed login-modal flows to authentication settings", async ({ + page, + authStateHelper, + authenticationPage, + }) => { + await test.step("Seed the simulator with a non-default admin password", async () => { + await authStateHelper.setState({ + password: testConfig.admin.password, + defaultPassword: DEFAULT_PASSWORD, + onboarded: true, + }); + }); + + await test.step("Open a protected settings route without an authenticated session", async () => { + await page.goto("/settings/general"); + }); + + await test.step("Dismiss the login modal", async () => { + await authenticationPage.validateLoginRequiredModal(); + await authenticationPage.dismissLoginRequiredModal(); + }); + + await test.step("Validate the user lands on the public authentication recovery page", async () => { + await authenticationPage.validateSettingsAuthenticationUrl(); + await authenticationPage.validateTitle("Update your admin login"); + await authenticationPage.validateTextIsVisible( + "Your admin login is used to modify performance settings or mining pool configurations for this miner.", + ); + }); + }); +}); diff --git a/server/fake-proto-rig/models.go b/server/fake-proto-rig/models.go index a2d75b0b7..ef85d5561 100644 --- a/server/fake-proto-rig/models.go +++ b/server/fake-proto-rig/models.go @@ -391,6 +391,27 @@ func (s *MinerState) SeedDefaultPassword(password string) { s.Password = password } +// ApplyTestingAuthState mutates the simulator auth/onboarding state for +// deterministic E2E setup and clears issued credentials afterward. +func (s *MinerState) ApplyTestingAuthState(password *string, defaultPassword *string, onboarded *bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if defaultPassword != nil { + s.DefaultPassword = *defaultPassword + } + if password != nil { + s.Password = *password + } + if onboarded != nil { + s.Onboarded = *onboarded + } + + s.AuthPublicKey = "" + s.AccessToken = "" + s.RefreshToken = "" +} + // SetPassword safely sets the password. func (s *MinerState) SetPassword(password string) { s.mu.Lock() diff --git a/server/fake-proto-rig/rest_api_handler.go b/server/fake-proto-rig/rest_api_handler.go index e300d77d1..6211ece09 100644 --- a/server/fake-proto-rig/rest_api_handler.go +++ b/server/fake-proto-rig/rest_api_handler.go @@ -546,6 +546,12 @@ type RESTApiHandler struct { state *MinerState } +type TestingAuthStateRequest struct { + Password *string `json:"password"` + DefaultPassword *string `json:"default_password"` + Onboarded *bool `json:"onboarded"` +} + // NewRESTApiHandler creates a new REST API handler func NewRESTApiHandler(state *MinerState) *RESTApiHandler { return &RESTApiHandler{state: state} @@ -556,10 +562,14 @@ func NewRESTApiHandler(state *MinerState) *RESTApiHandler { // POST /auth/refresh, GET /system, /system/status, /system/ssh, // /system/secure, /system/unlock, /system/tag, /system/telemetry, // /network, /pairing/info, POST /pairing/auth-key. +// - Simulator-only testing helpers: public endpoints under /testing used by +// Playwright to seed deterministic fake-rig state. // - DEFAULT_PASSWORD_EXEMPT_PREFIXES (auth required but not password-gated): // /auth/change-password and /pools (all verbs, all sub-paths). // - Everything else: auth required AND blocked while default_password_active. func (h *RESTApiHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/testing/auth-state", h.handleTestingAuthState) + // Pools: auth required, exempt from the default-password gate per firmware. // Fleet onboarding configures pools before the operator changes the password. mux.HandleFunc("/api/v1/pools", h.requireBearerAuth(h.handlePools)) @@ -1441,6 +1451,29 @@ func (h *RESTApiHandler) handleSystemStatus(w http.ResponseWriter, r *http.Reque }) } +func (h *RESTApiHandler) handleTestingAuthState(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + h.writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed") + return + } + + var req TestingAuthStateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request body") + return + } + + h.state.ApplyTestingAuthState(req.Password, req.DefaultPassword, req.Onboarded) + + passwordSet := h.state.GetPassword() != "" + + h.writeJSON(w, http.StatusOK, SystemStatuses{ + Onboarded: h.state.IsOnboarded(), + PasswordSet: passwordSet, + DefaultPasswordActive: h.state.IsDefaultPasswordActive(), + }) +} + func (h *RESTApiHandler) handleReboot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { h.writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed") diff --git a/server/fake-proto-rig/rest_api_handler_test.go b/server/fake-proto-rig/rest_api_handler_test.go index 801c84128..1fb6a2a34 100644 --- a/server/fake-proto-rig/rest_api_handler_test.go +++ b/server/fake-proto-rig/rest_api_handler_test.go @@ -46,6 +46,45 @@ func TestConfigureStartupAuthState_SeedsDefaultPasswordBaseline(t *testing.T) { } } +func TestHandleTestingAuthState_UpdatesStateAndRevokesCredentials(t *testing.T) { + state := NewMinerState("PROTO-SIM-12345678", "00:11:22:33:44:55") + state.SetPassword("old-password") + state.SetAuthKey("old-auth-key") + state.SetAccessToken("old-access-token") + state.SetRefreshToken("old-refresh-token") + h := NewRESTApiHandler(state) + + rr := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/v1/testing/auth-state", + strings.NewReader(`{"password":"factory-pass","default_password":"factory-pass","onboarded":true}`), + ) + h.handleTestingAuthState(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d; body=%s", http.StatusOK, rr.Code, rr.Body.String()) + } + if state.GetPassword() != "factory-pass" { + t.Fatalf("expected password to be updated, got %q", state.GetPassword()) + } + if !state.IsDefaultPasswordActive() { + t.Fatal("expected default password to be active after seeding matching password values") + } + if !state.IsOnboarded() { + t.Fatal("expected onboarded to be true") + } + if state.GetAuthKey() != "" { + t.Fatalf("expected auth key to be cleared, got %q", state.GetAuthKey()) + } + if state.GetAccessToken() != "" { + t.Fatalf("expected access token to be cleared, got %q", state.GetAccessToken()) + } + if state.GetRefreshToken() != "" { + t.Fatalf("expected refresh token to be cleared, got %q", state.GetRefreshToken()) + } +} + func TestHandleChangePassword_WrongCurrentPassword_Returns401(t *testing.T) { state := NewMinerState("SN12345678", "00:11:22:33:44:55") state.SetPassword("correctPassword")