fix(login): split passwordLogin / devLogin so SIGN IN cannot bypass

Oracle round-3 finding: passing password=undefined to api.signIn silently
hit /api/v1/auth/dev-login. With dev-login disabled in a production
build, a buyer typing only their email and pressing SIGN IN would still
have been authenticated as that user.

- api.passwordLogin(email, password) explicitly requires a password and
  always hits /api/v1/auth/login. Throws on missing password.
- api.devLogin(email) explicitly hits /api/v1/auth/dev-login and is the
  only entry point to that endpoint.
- store.loginAs(email, password, { method: 'password' | 'dev' }) routes
  to the right call. Default is password (requires password).
- Login.handleSubmit rejects empty password with 'Password required'
  before any network call.
- Login.handleDevLogin bails when devLoginEnabled is false.
- SSO button is disabled while probe state is 'unknown'.
- Login.test.tsx: regression test 'blocks empty-password SIGN IN —
  must NOT silently call dev-login'. Plus SSO test asserts the button
  starts disabled.

29/29 unit tests green.
This commit is contained in:
2026-06-14 13:34:54 +04:00
parent 456243c31c
commit b507ffe7e3
4 changed files with 61 additions and 44 deletions
+11 -31
View File
@@ -22,58 +22,43 @@ describe("Login Scene", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default store mock
(useApp as any).mockImplementation((selector: any) => {
const state = {
setScene: mockSetScene,
loginAs: mockLoginAs,
};
const state = { setScene: mockSetScene, loginAs: mockLoginAs };
return selector(state);
});
// Default api mock
(api.devLoginConfig as any).mockResolvedValue({ enabled: false });
globalThis.fetch = vi.fn(async () => new Response("", { status: 404 })) as any;
});
it("renders standard login form elements", async () => {
render(<Login />);
expect(screen.getByText("FLOWMASTER AUTHENTICATION")).toBeDefined();
expect(screen.getByLabelText(/OPERATOR_ID/i)).toBeDefined();
expect(screen.getByLabelText(/PASSPHRASE/i)).toBeDefined();
expect(screen.getAllByText("SIGN IN")[0]).toBeDefined();
expect(screen.getAllByText("CONTINUE WITH MICROSOFT")[0]).toBeDefined();
expect(screen.getAllByText(/MICROSOFT SIGN-IN|CONTINUE WITH MICROSOFT/i)[0]).toBeDefined();
});
it("calls loginAs and setScene on valid form submission", async () => {
it("calls loginAs with password method on valid form submission", async () => {
mockLoginAs.mockResolvedValueOnce(undefined);
render(<Login />);
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
fireEvent.change(screen.getByLabelText(/PASSPHRASE/i), { target: { value: 'password123' } });
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
await waitFor(() => {
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123');
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123', { method: 'password' });
expect(mockSetScene).toHaveBeenCalledWith('landing');
});
});
it("displays error on login failure", async () => {
mockLoginAs.mockRejectedValueOnce(new Error("Invalid credentials"));
it("blocks empty-password SIGN IN — must NOT silently call dev-login", async () => {
render(<Login />);
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
await waitFor(() => {
expect(screen.getByText("Invalid credentials")).toBeDefined();
expect(mockSetScene).not.toHaveBeenCalled();
expect(screen.getByText(/Password required/i)).toBeDefined();
});
expect(mockLoginAs).not.toHaveBeenCalled();
});
it("shows dev-login button when feature flag is enabled", async () => {
@@ -86,16 +71,11 @@ describe("Login Scene", () => {
});
});
it("redirects to SSO endpoint on microsoft click", async () => {
// We can't actually assert on window.location.href in this test environment
// without mocking window.location, but we can verify the button renders
// and click it to ensure no errors
it("SSO button is disabled until probe resolves to ready", async () => {
render(<Login />);
const ssoBtn = screen.getAllByRole("button", { name: /CONTINUE WITH MICROSOFT/i })[0];
const ssoBtn = screen.getAllByRole("button", { name: /MICROSOFT SIGN-IN|CONTINUE WITH MICROSOFT/i })[0] as HTMLButtonElement;
expect(ssoBtn.disabled).toBe(true);
fireEvent.click(ssoBtn);
// We just verify it doesn't try to call the regular login
expect(mockLoginAs).not.toHaveBeenCalled();
});
});
+21 -5
View File
@@ -35,10 +35,14 @@ export default function Login() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
if (!password) {
setError("Password required. Use the developer button below if dev-login is enabled.");
return;
}
setLoading(true);
setError(null);
try {
await loginAs(email, password || undefined);
await loginAs(email, password, { method: "password" });
setScene("landing");
} catch (err) {
setError((err as Error).message);
@@ -48,6 +52,10 @@ export default function Login() {
};
const handleDevLogin = async () => {
if (!devLoginEnabled) {
setError("Developer sign-in is disabled in this build.");
return;
}
if (!email) {
setError("Email required for dev-login");
return;
@@ -55,7 +63,7 @@ export default function Login() {
setLoading(true);
setError(null);
try {
await loginAs(email, undefined);
await loginAs(email, undefined, { method: "dev" });
setScene("landing");
} catch (err) {
setError((err as Error).message);
@@ -158,8 +166,13 @@ export default function Login() {
type="button"
className="sso-btn"
onClick={handleSSO}
disabled={loading || ssoState === "not-configured" || ssoState === "not-wired"}
title={ssoState === "not-configured" ? "Microsoft sign-in is not yet configured for this tenant" : ssoState === "not-wired" ? "Microsoft sign-in is not wired to this environment yet" : "Sign in with Microsoft"}
disabled={loading || ssoState !== "ready"}
title={
ssoState === "ready" ? "Sign in with Microsoft"
: ssoState === "not-configured" ? "Microsoft sign-in is not yet configured for this tenant"
: ssoState === "not-wired" ? "Microsoft sign-in is not wired to this environment yet"
: "Checking Microsoft sign-in availability…"
}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 21 21">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
@@ -167,7 +180,10 @@ export default function Login() {
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
{ssoState === "not-configured" ? "MICROSOFT SIGN-IN · NOT CONFIGURED" : ssoState === "not-wired" ? "MICROSOFT SIGN-IN · NOT WIRED" : "CONTINUE WITH MICROSOFT"}
{ssoState === "unknown" ? "MICROSOFT SIGN-IN · CHECKING…"
: ssoState === "not-configured" ? "MICROSOFT SIGN-IN · NOT CONFIGURED"
: ssoState === "not-wired" ? "MICROSOFT SIGN-IN · NOT WIRED"
: "CONTINUE WITH MICROSOFT"}
</button>
{devLoginEnabled && (