Skip to content
Open
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
5 changes: 5 additions & 0 deletions client/e2eTests/protoOS/fixtures/pageFixtures.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -27,6 +28,7 @@ type PageFixtures = {
generalPage: GeneralPage;
hardwarePage: HardwarePage;
coolingPage: CoolingPage;
authStateHelper: AuthStateHelper;
firmwareHelper: FirmwareHelper;
commonSteps: CommonSteps;
navigationComponent: NavigationComponent;
Expand Down Expand Up @@ -63,6 +65,9 @@ export const test = base.extend<PageFixtures>({
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));
},
Expand Down
93 changes: 93 additions & 0 deletions client/e2eTests/protoOS/helpers/authStateHelper.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
22 changes: 21 additions & 1 deletion client/e2eTests/protoOS/pages/authentication.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Comment on lines +13 to +18

async dismissLoginRequiredModal() {
await this.page.getByTestId("modal").getByRole("button", { name: "Cancel" }).click();
}
}
63 changes: 63 additions & 0 deletions client/e2eTests/protoOS/spec/authRedirects.spec.ts
Original file line number Diff line number Diff line change
@@ -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.",
);
});
});
});
21 changes: 21 additions & 0 deletions server/fake-proto-rig/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
33 changes: 33 additions & 0 deletions server/fake-proto-rig/rest_api_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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.
Comment on lines +565 to +566
// - 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))
Expand Down Expand Up @@ -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
}
Comment on lines +1459 to +1464

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")
Expand Down
39 changes: 39 additions & 0 deletions server/fake-proto-rig/rest_api_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down