Skip to content

Commit f312d85

Browse files
authored
fix(console): let the mock first-run wizard continue into the console (#205)
* fix(console): let the mock first-run wizard continue into the console In mock mode with first run forced open, login always acted like a browser holding no certificate, even after the wizard finished or was skipped, so a reviewer could never get past the operator certificate step. Finishing or skipping the admin step now closes mock first run (closeMockBootstrap), and the install step signs straight in as the mock admin instead of reloading, which would only reset the mock state back to open. Live mode is unchanged: it still reloads and still needs a real operator certificate. Closes #198 Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> * fix(console): sort the new bootstrap import with the other externals Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --------- Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Co-authored-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
1 parent 612db85 commit f312d85

6 files changed

Lines changed: 140 additions & 30 deletions

File tree

‎AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ components, named exports from `src/index.ts`) and
9595
`FirstRunWizard` (`src/components/first-run/`). `src/lib/bootstrap.ts` keeps the session secret
9696
in module memory only and sends it as the `Fleetos-Bootstrap-Session` header; never put it in
9797
web storage, a cookie or a URL. Mock mode keeps first run closed; set
98-
`VITE_BOOTSTRAP_MOCK_STATE=open` to try the wizard offline.
98+
`VITE_BOOTSTRAP_MOCK_STATE=open` to try the wizard offline. With it open, finishing or skipping
99+
the admin step calls `closeMockBootstrap()` and the install step's button signs straight in as
100+
the mock admin, since mock mode has no browser certificate for a reload to pick up.
99101
- Operator CA registration is a preview then a confirm with `confirm_sha256`.
100102
`OperatorCARegisterForm` takes the submit call as a prop, so the admin page and first run share
101103
it; `registerRequestFields()` builds the body both RegisterOperatorCA RPCs take.

‎apps/console/src/components/first-run/first-run-wizard.test.tsx‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
2525
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2626

2727
import { FirstRunWizard } from "@/components/first-run/first-run-wizard";
28-
import { __resetBootstrap } from "@/lib/bootstrap";
28+
import { AuthProvider, useAuth } from "@/context/auth";
29+
import { __resetBootstrap, isMockFirstRunOpen } from "@/lib/bootstrap";
2930
import { base64 } from "@/lib/crypto/leaf-key";
3031
import { colonFingerprint } from "@/lib/fingerprint";
3132
import * as modeMod from "@/lib/fleet/mode";
@@ -91,6 +92,14 @@ const signLeaf = async (ca: Awaited<ReturnType<typeof makeCA>>, publicKey: Crypt
9192
const fill = (label: RegExp, value: string, root: HTMLElement = document.body) =>
9293
fireEvent.change(within(root).getByLabelText(label), { target: { value } });
9394

95+
// Reads the auth status next to the wizard, the way the real console gates on
96+
// it, so a test can prove the install step actually signs the operator in
97+
// rather than only checking which button it renders.
98+
const AuthStatusProbe = () => {
99+
const { status } = useAuth();
100+
return <span data-testid="auth-status">{status}</span>;
101+
};
102+
94103
const captureKeys = () => {
95104
const generated: CryptoKeyPair[] = [];
96105
const real = crypto.subtle.generateKey.bind(crypto.subtle);
@@ -218,10 +227,15 @@ describe("FirstRunWizard (mock mode)", () => {
218227
await expectNoA11yViolations(container);
219228
}, 30_000);
220229

221-
it("path A: token, CA with fingerprint confirm, key backup before the CSR, upload, PKCS#12, install", async () => {
230+
it("path A: token, CA with fingerprint confirm, key backup before the CSR, upload, PKCS#12, install, then continue signs in", async () => {
222231
const generated = captureKeys();
223232
const ca = await makeCA();
224-
render(<FirstRunWizard inProgress={false} tokenExpiresAt="" />);
233+
render(
234+
<AuthProvider>
235+
<AuthStatusProbe />
236+
<FirstRunWizard inProgress={false} tokenExpiresAt="" />
237+
</AuthProvider>,
238+
);
225239
await startSession();
226240
await registerCA(ca);
227241
const passphrase = await pathA(ca, generated);
@@ -234,12 +248,23 @@ describe("FirstRunWizard (mock mode)", () => {
234248
expect(args.friendlyName).toBe(`FleetOS admin (${EMAIL})`);
235249
expect(downloadBytes.mock.calls[0][0]).toBe(`${EMAIL}.p12`);
236250
expect(screen.getByText(/Current User/)).toBeInTheDocument();
237-
expect(screen.getByRole("button", { name: /reload/i })).toBeInTheDocument();
251+
252+
// Finishing path A already closed mock first run (#198): no reload is
253+
// needed, and continuing signs straight in as the mock admin.
254+
expect(isMockFirstRunOpen()).toBe(false);
255+
expect(screen.getByTestId("auth-status")).toHaveTextContent("anonymous");
256+
fireEvent.click(screen.getByRole("button", { name: /continue to the console/i }));
257+
expect(screen.getByTestId("auth-status")).toHaveTextContent("authenticated");
238258
}, 60_000);
239259

240-
it("path B: the recipe, an optional pre-flight, then install", async () => {
260+
it("path B: the recipe, an optional pre-flight, then skipping to install closes mock first run too", async () => {
241261
const ca = await makeCA();
242-
render(<FirstRunWizard inProgress={false} tokenExpiresAt="" />);
262+
render(
263+
<AuthProvider>
264+
<AuthStatusProbe />
265+
<FirstRunWizard inProgress={false} tokenExpiresAt="" />
266+
</AuthProvider>,
267+
);
243268
await startSession();
244269
await registerCA(ca);
245270
fireEvent.click(screen.getByLabelText(/path b/i));
@@ -250,6 +275,10 @@ describe("FirstRunWizard (mock mode)", () => {
250275
).toContain("op_admin");
251276
fireEvent.click(screen.getByRole("button", { name: /skip to install/i }));
252277
await screen.findByRole("heading", { name: /install your admin certificate/i });
278+
279+
expect(isMockFirstRunOpen()).toBe(false);
280+
fireEvent.click(screen.getByRole("button", { name: /continue to the console/i }));
281+
expect(screen.getByTestId("auth-status")).toHaveTextContent("authenticated");
253282
}, 60_000);
254283
});
255284

@@ -307,5 +336,9 @@ describe("FirstRunWizard (live mode)", () => {
307336
].join("\n");
308337
expect(stored).not.toContain("fos_bsess_live");
309338
expect(stored).not.toContain(passphrase);
339+
340+
// Live mode keeps needing a real reload for the browser to present the
341+
// newly installed certificate (#198 is mock-only).
342+
expect(screen.getByRole("button", { name: /reload and sign in/i })).toBeInTheDocument();
310343
}, 60_000);
311344
});

‎apps/console/src/components/first-run/first-run-wizard.tsx‎

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import { AdminStep } from "@/components/first-run/admin-step";
2121
import { CaStep } from "@/components/first-run/ca-step";
2222
import { TokenStep } from "@/components/first-run/token-step";
2323
import { CertificateHelp } from "@/components/layout/certificate-help";
24-
import { endBootstrapSession } from "@/lib/bootstrap";
24+
import { useOptionalAuth } from "@/context/auth";
25+
import { closeMockBootstrap, endBootstrapSession } from "@/lib/bootstrap";
26+
import { fleetMode } from "@/lib/fleet/mode";
2527
import { levelExtfileSection } from "@/lib/operators";
2628

2729
type Step = "admin" | "ca" | "install" | "token";
@@ -33,24 +35,33 @@ const STEPS: { id: Step; label: string }[] = [
3335
{ id: "install", label: "Install" },
3436
];
3537

36-
const InstallStep = () => (
37-
<div className="space-y-4">
38-
<h2 className="text-lg font-bold">Install your admin certificate</h2>
39-
<p className="text-sm text-muted-foreground">
40-
Import the PKCS#12 with its passphrase (the key backup&apos;s), then reload this page and
41-
choose the FleetOS certificate when the browser asks. If it doesn&apos;t ask, quit the browser
42-
fully and start it again: Chrome remembers a &quot;no certificate&quot; answer per site. Your
43-
first sign-in as admin closes first run.
44-
</p>
45-
<CertificateHelp />
46-
<p className="text-xs text-muted-foreground">
47-
Keep the key backup somewhere safe, or delete it once the PKCS#12 is imported.
48-
</p>
49-
<Button onClick={() => globalThis.location.reload()} size="sm">
50-
Reload and sign in
51-
</Button>
52-
</div>
53-
);
38+
// In mock mode there is no browser certificate for a reload to pick up, so
39+
// finishing or skipping the admin step (FirstRunWizard's onDone) already
40+
// closed mock first run; the button here signs straight in instead of
41+
// reloading, which would only reset the mock state back to open.
42+
const InstallStep = () => {
43+
const auth = useOptionalAuth();
44+
const mock = fleetMode() === "mock";
45+
46+
return (
47+
<div className="space-y-4">
48+
<h2 className="text-lg font-bold">Install your admin certificate</h2>
49+
<p className="text-sm text-muted-foreground">
50+
Import the PKCS#12 with its passphrase (the key backup&apos;s), then reload this page and
51+
choose the FleetOS certificate when the browser asks. If it doesn&apos;t ask, quit the
52+
browser fully and start it again: Chrome remembers a &quot;no certificate&quot; answer per
53+
site. Your first sign-in as admin closes first run.
54+
</p>
55+
<CertificateHelp />
56+
<p className="text-xs text-muted-foreground">
57+
Keep the key backup somewhere safe, or delete it once the PKCS#12 is imported.
58+
</p>
59+
<Button onClick={() => (mock ? auth?.login() : globalThis.location.reload())} size="sm">
60+
{mock ? "Continue to the console" : "Reload and sign in"}
61+
</Button>
62+
</div>
63+
);
64+
};
5465

5566
// FirstRunWizard stands a Fleet Manager up from nothing: the bootstrap token,
5667
// then the external operator CA (with the fingerprint confirm), then the
@@ -115,7 +126,10 @@ export const FirstRunWizard = ({
115126
<AdminStep
116127
adminExtfile={adminExtfile}
117128
anchorDer={anchorDer}
118-
onDone={() => setStep("install")}
129+
onDone={() => {
130+
if (fleetMode() === "mock") closeMockBootstrap();
131+
setStep("install");
132+
}}
119133
/>
120134
</>
121135
) : null}

‎apps/console/src/context/auth.test.tsx‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ limitations under the License.
1515
*/
1616

1717
import { Code, ConnectError } from "@connectrpc/connect";
18+
import { BootstrapState } from "@cryptos-pki/api-client/cryptos/fleet/v1/bootstrap_pb";
1819
import { render, screen, waitFor } from "@testing-library/react";
1920
import { beforeEach, describe, expect, it, vi } from "vitest";
2021

2122
import { AuthProvider, useAuth } from "@/context/auth";
23+
import { __resetBootstrap, __setMockBootstrapState, closeMockBootstrap } from "@/lib/bootstrap";
2224

2325
const whoAmI = vi.fn();
2426
const fleetMode = vi.fn(() => "live");
@@ -57,6 +59,7 @@ describe("AuthProvider", () => {
5759
probe.mockReset();
5860
fleetMode.mockReturnValue("live");
5961
vi.stubGlobal("fetch", probe);
62+
__resetBootstrap();
6063
});
6164

6265
// The point of #68: arriving at the page must not sign you in, even holding a
@@ -231,4 +234,43 @@ describe("AuthProvider", () => {
231234
await waitFor(() => expect(state()).toBe("authenticated:admin:none"));
232235
expect(whoAmI).not.toHaveBeenCalled();
233236
});
237+
238+
// #198: with mock first run forced open, login used to always act like a
239+
// browser holding no certificate, even after the wizard finished, so a
240+
// reviewer could never get past it.
241+
it("acts like a missing certificate while mock first run is open", async () => {
242+
fleetMode.mockReturnValue("mock");
243+
__setMockBootstrapState(BootstrapState.OPEN);
244+
renderProbe();
245+
246+
screen.getByRole("button", { name: /sign in/i }).click();
247+
248+
await waitFor(() => expect(state()).toBe("denied:none:no-certificate"));
249+
});
250+
251+
it("signs in as the mock admin once the wizard closes mock first run", async () => {
252+
fleetMode.mockReturnValue("mock");
253+
__setMockBootstrapState(BootstrapState.OPEN);
254+
renderProbe();
255+
256+
screen.getByRole("button", { name: /sign in/i }).click();
257+
await waitFor(() => expect(state()).toBe("denied:none:no-certificate"));
258+
259+
closeMockBootstrap();
260+
screen.getByRole("button", { name: /sign in/i }).click();
261+
262+
await waitFor(() => expect(state()).toBe("authenticated:admin:none"));
263+
});
264+
265+
// Live mode never reads the mock bootstrap state: closing it has no effect
266+
// here, and a real certificate is still required.
267+
it("ignores mock first-run state in live mode", async () => {
268+
closeMockBootstrap();
269+
whoAmI.mockRejectedValue(new ConnectError("no cert", Code.Unauthenticated));
270+
renderProbe();
271+
272+
screen.getByRole("button", { name: /sign in/i }).click();
273+
274+
await waitFor(() => expect(state()).toBe("denied:none:certificate-not-sent"));
275+
});
234276
});

‎apps/console/src/context/auth.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
import { Code, ConnectError } from "@connectrpc/connect";
1818
import { createContext, type ReactNode, useCallback, useContext, useState } from "react";
1919

20+
import { isMockFirstRunOpen } from "@/lib/bootstrap";
2021
import { fleetClient } from "@/lib/fleet/client";
2122
import { fleetMode } from "@/lib/fleet/mode";
2223

@@ -162,9 +163,11 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
162163
setReason(null);
163164

164165
if (fleetMode() === "mock") {
165-
// With mock first run open, act like a browser holding no certificate,
166-
// so the first-run wizard can be tried offline.
167-
if (import.meta.env.VITE_BOOTSTRAP_MOCK_STATE === "open") {
166+
// With mock first run still open, act like a browser holding no
167+
// certificate, so the first-run wizard can be tried offline. Once the
168+
// wizard closes it (closeMockBootstrap, on finishing or skipping the
169+
// admin step), sign in below like any other mock login.
170+
if (isMockFirstRunOpen()) {
168171
setOperator(null);
169172
setReason("no-certificate");
170173
setStatus("denied");

‎apps/console/src/lib/bootstrap.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,22 @@ export const getBootstrapState = async (): Promise<BootstrapStatus> => {
9797

9898
export const hasBootstrapSession = (): boolean => sessionSecret !== null;
9999

100+
// isMockFirstRunOpen tells mock-mode sign-in whether first run is still open,
101+
// so it knows to act like a browser holding no certificate. Reading the live
102+
// mockState rather than the env var means it reflects closeMockBootstrap()
103+
// once the wizard reaches the end.
104+
export const isMockFirstRunOpen = (): boolean =>
105+
mockState === BootstrapState.OPEN || mockState === BootstrapState.OPEN_IN_PROGRESS;
106+
107+
// closeMockBootstrap closes mock first run the way a live admin's first
108+
// authenticated sign-in closes the real thing. Mock mode has no browser
109+
// certificate to present on a reload, so the wizard calls this directly once
110+
// the operator finishes or skips the admin step, instead of relying on a
111+
// reload that would only reset the mock state back to open.
112+
export const closeMockBootstrap = (): void => {
113+
mockState = BootstrapState.CLOSED;
114+
};
115+
100116
export const endBootstrapSession = (): void => {
101117
sessionSecret = null;
102118
};

0 commit comments

Comments
 (0)