mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
fix login and known hosts
This commit is contained in:
parent
6074d9d6e4
commit
a75abe1454
26 changed files with 618 additions and 96 deletions
|
|
@ -27,6 +27,7 @@ function makeUser(overrides: Partial<Data.ServerInfo_User> = {}): Data.ServerInf
|
||||||
export const disconnectedState: Partial<RootState> = {
|
export const disconnectedState: Partial<RootState> = {
|
||||||
server: {
|
server: {
|
||||||
initialized: false,
|
initialized: false,
|
||||||
|
testConnectionStatus: null,
|
||||||
buddyList: {},
|
buddyList: {},
|
||||||
ignoreList: {},
|
ignoreList: {},
|
||||||
status: {
|
status: {
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ export class SessionResponseImpl implements WebsocketTypes.ISessionResponse {
|
||||||
ServerDispatch.connectionFailed();
|
ServerDispatch.connectionFailed();
|
||||||
}
|
}
|
||||||
|
|
||||||
testConnectionSuccessful(): void {
|
testConnectionSuccessful(serverOptions: number): void {
|
||||||
ServerDispatch.testConnectionSuccessful();
|
ServerDispatch.testConnectionSuccessful(serverOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
testConnectionFailed(): void {
|
testConnectionFailed(): void {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => {
|
||||||
const {
|
const {
|
||||||
hosts,
|
hosts,
|
||||||
selectedHost,
|
selectedHost,
|
||||||
testingConnection,
|
testConnectionStatus,
|
||||||
dialogState,
|
dialogState,
|
||||||
onPick,
|
onPick,
|
||||||
openAddKnownHostDialog,
|
openAddKnownHostDialog,
|
||||||
|
|
@ -119,8 +119,8 @@ const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => {
|
||||||
<MenuItem value={host.id} key={host.id}>
|
<MenuItem value={host.id} key={host.id}>
|
||||||
<div className="KnownHosts-item">
|
<div className="KnownHosts-item">
|
||||||
<div className="KnownHosts-item__wrapper">
|
<div className="KnownHosts-item__wrapper">
|
||||||
<div className={`KnownHosts-item__status ${testingConnection ?? ''}`}>
|
<div className={`KnownHosts-item__status ${testConnectionStatus ?? ''}`}>
|
||||||
{testingConnection === TestConnection.FAILED ? (
|
{testConnectionStatus === TestConnection.FAILED ? (
|
||||||
<PortableWifiOffIcon fontSize="small" />
|
<PortableWifiOffIcon fontSize="small" />
|
||||||
) : (
|
) : (
|
||||||
<WifiTetheringIcon fontSize="small" />
|
<WifiTetheringIcon fontSize="small" />
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { useToast } from '@app/components';
|
import { useToast } from '@app/components';
|
||||||
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||||
import { getHostPort, HostDTO } from '@app/services';
|
import { getHostPort, HostDTO } from '@app/services';
|
||||||
import { ServerTypes } from '@app/store';
|
import { ServerDispatch, ServerSelectors, ServerTypes, useAppSelector } from '@app/store';
|
||||||
import { App } from '@app/types';
|
import { App } from '@app/types';
|
||||||
|
import { passwordSaltSupported } from '@app/websocket';
|
||||||
|
|
||||||
export enum TestConnection {
|
export enum TestConnection {
|
||||||
TESTING = 'testing',
|
TESTING = 'testing',
|
||||||
|
|
@ -16,7 +17,7 @@ export enum TestConnection {
|
||||||
export interface KnownHostsComponent {
|
export interface KnownHostsComponent {
|
||||||
hosts: App.Host[];
|
hosts: App.Host[];
|
||||||
selectedHost: App.Host | undefined;
|
selectedHost: App.Host | undefined;
|
||||||
testingConnection: TestConnection | null;
|
testConnectionStatus: TestConnection | null;
|
||||||
dialogState: { open: boolean; edit: HostDTO | null };
|
dialogState: { open: boolean; edit: HostDTO | null };
|
||||||
onPick: (id: number) => Promise<void>;
|
onPick: (id: number) => Promise<void>;
|
||||||
openAddKnownHostDialog: () => void;
|
openAddKnownHostDialog: () => void;
|
||||||
|
|
@ -55,9 +56,13 @@ export function useKnownHostsComponent({
|
||||||
edit: null,
|
edit: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
|
// UI status lives in redux (see ServerSelectors.getTestConnectionStatus) so
|
||||||
// Tracks the host currently awaiting a testConnection response. If null when a
|
// the LoginForm can gate its submit button + hashing-capability UI on the
|
||||||
// response arrives, the caller has moved on — ignore the stale reply.
|
// same signal. Tracks-the-host-pending lives in a ref — redux doesn't know
|
||||||
|
// the Host.id, and we need it to persist `supportsHashedPassword` on success.
|
||||||
|
const testConnectionStatus = useAppSelector(ServerSelectors.getTestConnectionStatus) as
|
||||||
|
| TestConnection
|
||||||
|
| null;
|
||||||
const pendingTestRef = useRef<HostDTO | null>(null);
|
const pendingTestRef = useRef<HostDTO | null>(null);
|
||||||
|
|
||||||
const selectedHost =
|
const selectedHost =
|
||||||
|
|
@ -66,7 +71,7 @@ export function useKnownHostsComponent({
|
||||||
|
|
||||||
const testConnection = (host: HostDTO) => {
|
const testConnection = (host: HostDTO) => {
|
||||||
pendingTestRef.current = host;
|
pendingTestRef.current = host;
|
||||||
setTestingConnection(TestConnection.TESTING);
|
ServerDispatch.testConnectionStarted();
|
||||||
webClient.request.authentication.testConnection({ ...getHostPort(host) });
|
webClient.request.authentication.testConnection({ ...getHostPort(host) });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -78,19 +83,20 @@ export function useKnownHostsComponent({
|
||||||
testConnection(selectedHost);
|
testConnection(selectedHost);
|
||||||
}, [selectedHost]);
|
}, [selectedHost]);
|
||||||
|
|
||||||
useReduxEffect(() => {
|
useReduxEffect<{ serverOptions: number }>(({ payload: { serverOptions } }) => {
|
||||||
if (!pendingTestRef.current) {
|
const host = pendingTestRef.current;
|
||||||
|
if (!host) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTestingConnection(TestConnection.SUCCESS);
|
|
||||||
pendingTestRef.current = null;
|
pendingTestRef.current = null;
|
||||||
|
|
||||||
|
const supportsHashedPassword = passwordSaltSupported(serverOptions);
|
||||||
|
if (host.id != null && host.supportsHashedPassword !== supportsHashedPassword) {
|
||||||
|
void knownHosts.update(host.id, { supportsHashedPassword });
|
||||||
|
}
|
||||||
}, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []);
|
}, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []);
|
||||||
|
|
||||||
useReduxEffect(() => {
|
useReduxEffect(() => {
|
||||||
if (!pendingTestRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTestingConnection(TestConnection.FAILED);
|
|
||||||
pendingTestRef.current = null;
|
pendingTestRef.current = null;
|
||||||
}, ServerTypes.TEST_CONNECTION_FAILED, []);
|
}, ServerTypes.TEST_CONNECTION_FAILED, []);
|
||||||
|
|
||||||
|
|
@ -163,7 +169,7 @@ export function useKnownHostsComponent({
|
||||||
return {
|
return {
|
||||||
hosts,
|
hosts,
|
||||||
selectedHost,
|
selectedHost,
|
||||||
testingConnection,
|
testConnectionStatus,
|
||||||
dialogState,
|
dialogState,
|
||||||
onPick,
|
onPick,
|
||||||
openAddKnownHostDialog,
|
openAddKnownHostDialog,
|
||||||
|
|
|
||||||
|
|
@ -172,8 +172,13 @@ describe('Login — logout cycle (same JS session)', () => {
|
||||||
await flushEffects();
|
await flushEffects();
|
||||||
first.unmount();
|
first.unmount();
|
||||||
|
|
||||||
|
// Submit button stays disabled until testConnectionStatus resolves to 'success';
|
||||||
|
// preload it so the click actually dispatches.
|
||||||
const { getByRole, queryByText } = renderWithProviders(<Login />, {
|
const { getByRole, queryByText } = renderWithProviders(<Login />, {
|
||||||
preloadedState: disconnectedState,
|
preloadedState: {
|
||||||
|
...disconnectedState,
|
||||||
|
server: { ...(disconnectedState.server as any), testConnectionStatus: 'success' },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await flushEffects();
|
await flushEffects();
|
||||||
|
|
||||||
|
|
@ -199,3 +204,96 @@ describe('Login — refresh cycle', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// End-to-end regression: the symptom reported on this branch was "save-password
|
||||||
|
// checkbox shows, login succeeds, but HostDTO.hashedPassword stays empty in
|
||||||
|
// Dexie". These tests wire the full chain — auto-login fires onSubmitLogin
|
||||||
|
// (capturing the form in rememberLoginRef), then a store.dispatch of
|
||||||
|
// LOGIN_SUCCESSFUL drives the useReduxEffect that calls knownHosts.update.
|
||||||
|
describe('Login — LOGIN_SUCCESSFUL → knownHosts persistence', () => {
|
||||||
|
const armWithUpdate = () => {
|
||||||
|
const update = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const host = makeHost({
|
||||||
|
id: 7,
|
||||||
|
remember: true,
|
||||||
|
userName: 'alice',
|
||||||
|
hashedPassword: 'stored-hash',
|
||||||
|
supportsHashedPassword: true,
|
||||||
|
lastSelected: true,
|
||||||
|
});
|
||||||
|
hoisted.useSettings.mockReturnValue(
|
||||||
|
makeSettingsHook({
|
||||||
|
status: LoadingState.READY,
|
||||||
|
value: makeSettings({ autoConnect: true }),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
hoisted.useKnownHosts.mockReturnValue(
|
||||||
|
makeKnownHostsHook({
|
||||||
|
status: LoadingState.READY,
|
||||||
|
value: { hosts: [host], selectedHost: host },
|
||||||
|
update,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
hoisted.getSettings.mockResolvedValue(makeSettings({ autoConnect: true }));
|
||||||
|
hoisted.getKnownHosts.mockResolvedValue({ hosts: [host], selectedHost: host });
|
||||||
|
return { update, hostId: host.id! };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('persists userName + hashedPassword when loginSuccessful carries a real hash', async () => {
|
||||||
|
const { update, hostId } = armWithUpdate();
|
||||||
|
|
||||||
|
const { store } = renderWithProviders(<Login />, {
|
||||||
|
preloadedState: {
|
||||||
|
...disconnectedState,
|
||||||
|
server: { ...(disconnectedState.server as any), testConnectionStatus: 'success' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
store.dispatch({
|
||||||
|
type: 'server/loginSuccessful',
|
||||||
|
payload: { options: { hashedPassword: 'real-hash-xyz' } },
|
||||||
|
});
|
||||||
|
await flushEffects();
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledWith(hostId, {
|
||||||
|
remember: true,
|
||||||
|
userName: 'alice',
|
||||||
|
hashedPassword: 'real-hash-xyz',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not persist credentials when hashedPassword is empty (empty-salt fallback)', async () => {
|
||||||
|
const { update, hostId } = armWithUpdate();
|
||||||
|
|
||||||
|
const { store } = renderWithProviders(<Login />, {
|
||||||
|
preloadedState: {
|
||||||
|
...disconnectedState,
|
||||||
|
server: { ...(disconnectedState.server as any), testConnectionStatus: 'success' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(hoisted.mockWebClient.request.authentication.login).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Empty-salt fallback path: login went through as plain password, so the
|
||||||
|
// response layer has no hash to carry forward.
|
||||||
|
store.dispatch({
|
||||||
|
type: 'server/loginSuccessful',
|
||||||
|
payload: { options: { hashedPassword: undefined } },
|
||||||
|
});
|
||||||
|
await flushEffects();
|
||||||
|
|
||||||
|
// Guard in useLogin.updateHost clears remember+credentials so next load
|
||||||
|
// reflects that save-password wasn't honoured — no stale "checked" checkbox
|
||||||
|
// sitting against a null hash.
|
||||||
|
expect(update).toHaveBeenCalledWith(hostId, {
|
||||||
|
remember: false,
|
||||||
|
userName: null,
|
||||||
|
hashedPassword: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -148,16 +148,23 @@ export function useLogin(): Login {
|
||||||
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
|
}, [ServerTypes.CONNECTION_FAILED, ServerTypes.LOGIN_FAILED], []);
|
||||||
|
|
||||||
const updateHost = (
|
const updateHost = (
|
||||||
hashedPassword: string,
|
hashedPassword: string | undefined,
|
||||||
{ selectedHost, remember, userName }: LoginFormValues,
|
{ selectedHost, remember, userName }: LoginFormValues,
|
||||||
) => {
|
) => {
|
||||||
if (selectedHost.id == null) {
|
if (selectedHost.id == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Only honour the Remember checkbox when we actually received a hash to
|
||||||
|
// store. A server that advertises SupportsPasswordHash but returns an empty
|
||||||
|
// salt falls through to a plain-password login (no hash produced); writing
|
||||||
|
// `remember: true` with a null hash would leave the checkbox visibly
|
||||||
|
// checked on next load while the stored-password flow silently couldn't
|
||||||
|
// activate. Resetting to the unchecked state makes the failure visible.
|
||||||
|
const persistCredentials = remember && Boolean(hashedPassword);
|
||||||
knownHosts.update(selectedHost.id, {
|
knownHosts.update(selectedHost.id, {
|
||||||
remember,
|
remember: persistCredentials,
|
||||||
userName: remember ? userName : null,
|
userName: persistCredentials ? userName : null,
|
||||||
hashedPassword: remember ? hashedPassword : null,
|
hashedPassword: persistCredentials ? hashedPassword : null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ beforeAll(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('LoginForm — regression: settings.autoConnect is not clobbered by host state', () => {
|
describe('LoginForm — regression: settings.autoConnect is not clobbered by host state', () => {
|
||||||
test('selecting a host with remember=false does NOT call settings.update', () => {
|
test('selecting a hashed-capable host with remember=false does NOT call settings.update', () => {
|
||||||
const update = vi.fn().mockResolvedValue(undefined);
|
const update = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
hoisted.mockUseSettings.mockReturnValue(
|
hoisted.mockUseSettings.mockReturnValue(
|
||||||
|
|
@ -44,6 +44,7 @@ describe('LoginForm — regression: settings.autoConnect is not clobbered by hos
|
||||||
remember: false,
|
remember: false,
|
||||||
userName: undefined,
|
userName: undefined,
|
||||||
hashedPassword: undefined,
|
hashedPassword: undefined,
|
||||||
|
supportsHashedPassword: true,
|
||||||
lastSelected: true,
|
lastSelected: true,
|
||||||
});
|
});
|
||||||
hoisted.mockUseKnownHosts.mockReturnValue(
|
hoisted.mockUseKnownHosts.mockReturnValue(
|
||||||
|
|
@ -78,6 +79,7 @@ describe('LoginForm — regression: settings.autoConnect is not clobbered by hos
|
||||||
remember: true,
|
remember: true,
|
||||||
userName: 'joe',
|
userName: 'joe',
|
||||||
hashedPassword: 'abc',
|
hashedPassword: 'abc',
|
||||||
|
supportsHashedPassword: true,
|
||||||
lastSelected: true,
|
lastSelected: true,
|
||||||
});
|
});
|
||||||
hoisted.mockUseKnownHosts.mockReturnValue(
|
hoisted.mockUseKnownHosts.mockReturnValue(
|
||||||
|
|
@ -95,3 +97,90 @@ describe('LoginForm — regression: settings.autoConnect is not clobbered by hos
|
||||||
expect(onSubmit).not.toHaveBeenCalled();
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('LoginForm — hashed-password gating', () => {
|
||||||
|
// The gate requires BOTH a successful test-connection AND host.supportsHashedPassword=true;
|
||||||
|
// preload testConnectionStatus='success' so the host flag is the only lever under test.
|
||||||
|
const testedState = {
|
||||||
|
...disconnectedState,
|
||||||
|
server: { ...(disconnectedState.server as any), testConnectionStatus: 'success' as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWith = (host: ReturnType<typeof makeHost>) => {
|
||||||
|
hoisted.mockUseSettings.mockReturnValue(
|
||||||
|
makeSettingsHook({
|
||||||
|
status: LoadingState.READY,
|
||||||
|
value: makeSettings({ autoConnect: false }),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
hoisted.mockUseKnownHosts.mockReturnValue(
|
||||||
|
makeKnownHostsHook({
|
||||||
|
status: LoadingState.READY,
|
||||||
|
value: { hosts: [host], selectedHost: host },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return renderWithProviders(
|
||||||
|
<LoginForm onSubmit={vi.fn()} disableSubmitButton={false} onResetPassword={vi.fn()} />,
|
||||||
|
{ preloadedState: testedState }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRemember = (root: HTMLElement) => Boolean(root.querySelector('input[name="remember"]'));
|
||||||
|
const hasAutoConnect = (root: HTMLElement) =>
|
||||||
|
Boolean(Array.from(root.querySelectorAll('.MuiFormControlLabel-root')).find((n) =>
|
||||||
|
n.textContent?.includes('LoginForm.label.autoConnect'),
|
||||||
|
));
|
||||||
|
|
||||||
|
test('hides Remember + Auto Connect when the host does not support hashed passwords', () => {
|
||||||
|
const { container } = renderWith(
|
||||||
|
makeHost({ id: 1, supportsHashedPassword: false, lastSelected: true })
|
||||||
|
);
|
||||||
|
expect(hasRemember(container)).toBe(false);
|
||||||
|
expect(hasAutoConnect(container)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides Remember + Auto Connect when the host has never been test-connected', () => {
|
||||||
|
const { container } = renderWith(
|
||||||
|
makeHost({ id: 1, supportsHashedPassword: undefined, lastSelected: true })
|
||||||
|
);
|
||||||
|
expect(hasRemember(container)).toBe(false);
|
||||||
|
expect(hasAutoConnect(container)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows Remember + Auto Connect when the host supports hashed passwords', () => {
|
||||||
|
const { container } = renderWith(
|
||||||
|
makeHost({ id: 1, supportsHashedPassword: true, lastSelected: true })
|
||||||
|
);
|
||||||
|
expect(hasRemember(container)).toBe(true);
|
||||||
|
expect(hasAutoConnect(container)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears settings.autoConnect when selecting a naked-password host with it previously on', () => {
|
||||||
|
const update = vi.fn().mockResolvedValue(undefined);
|
||||||
|
hoisted.mockUseSettings.mockReturnValue(
|
||||||
|
makeSettingsHook({
|
||||||
|
status: LoadingState.READY,
|
||||||
|
value: makeSettings({ autoConnect: true }),
|
||||||
|
update,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const host = makeHost({
|
||||||
|
id: 1,
|
||||||
|
supportsHashedPassword: false,
|
||||||
|
remember: true,
|
||||||
|
lastSelected: true,
|
||||||
|
});
|
||||||
|
hoisted.mockUseKnownHosts.mockReturnValue(
|
||||||
|
makeKnownHostsHook({
|
||||||
|
status: LoadingState.READY,
|
||||||
|
value: { hosts: [host], selectedHost: host },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
renderWithProviders(
|
||||||
|
<LoginForm onSubmit={vi.fn()} disableSubmitButton={false} onResetPassword={vi.fn()} />,
|
||||||
|
{ preloadedState: disconnectedState }
|
||||||
|
);
|
||||||
|
expect(update).toHaveBeenCalledWith({ autoConnect: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,22 @@ import { CheckboxField, InputField, KnownHosts } from '@app/components';
|
||||||
import type { FormErrors } from '@app/forms';
|
import type { FormErrors } from '@app/forms';
|
||||||
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
||||||
import { HostDTO } from '@app/services';
|
import { HostDTO } from '@app/services';
|
||||||
|
import { ServerSelectors, TestConnectionStatus, useAppSelector } from '@app/store';
|
||||||
|
|
||||||
import { useLoginFormBody } from './useLoginForm';
|
import { useLoginFormBody } from './useLoginForm';
|
||||||
|
|
||||||
import './LoginForm.css';
|
import './LoginForm.css';
|
||||||
|
|
||||||
|
// Remember Password and Auto Connect both require server-side password hashing
|
||||||
|
// to be useful (no hash to save = nothing to resume with). Test-connection
|
||||||
|
// captures capability from ServerIdentification before the user ever logs in,
|
||||||
|
// so we can afford a strict "hidden until a completed test proves supported" gate.
|
||||||
|
const hostSupportsHashedPassword = (
|
||||||
|
host: HostDTO | undefined,
|
||||||
|
testConnectionStatus: TestConnectionStatus,
|
||||||
|
): boolean =>
|
||||||
|
testConnectionStatus === 'success' && host?.supportsHashedPassword === true;
|
||||||
|
|
||||||
export interface LoginFormValues {
|
export interface LoginFormValues {
|
||||||
userName: string;
|
userName: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|
@ -47,6 +58,7 @@ const LoginFormBody = ({
|
||||||
const STORED_PASSWORD_LABEL = t('LoginForm.label.savedPassword');
|
const STORED_PASSWORD_LABEL = t('LoginForm.label.savedPassword');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
selectedHost,
|
||||||
useStoredPasswordLabel,
|
useStoredPasswordLabel,
|
||||||
setUseStoredPasswordLabel,
|
setUseStoredPasswordLabel,
|
||||||
onSelectedHostChange,
|
onSelectedHostChange,
|
||||||
|
|
@ -56,6 +68,13 @@ const LoginFormBody = ({
|
||||||
passwordFieldBlur,
|
passwordFieldBlur,
|
||||||
} = useLoginFormBody(form);
|
} = useLoginFormBody(form);
|
||||||
|
|
||||||
|
const testConnectionStatus = useAppSelector(ServerSelectors.getTestConnectionStatus);
|
||||||
|
const showHashingGatedOptions = hostSupportsHashedPassword(selectedHost, testConnectionStatus);
|
||||||
|
// Login is only meaningful once we know the host is reachable + speaks the
|
||||||
|
// Cockatrice protocol. Keep the button disabled until test-connection resolves
|
||||||
|
// to 'success'; re-disable on any subsequent re-test.
|
||||||
|
const loginDisabled = disableSubmitButton || testConnectionStatus !== 'success';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="loginForm" onSubmit={handleSubmit}>
|
<form className="loginForm" onSubmit={handleSubmit}>
|
||||||
<div className="loginForm-items">
|
<div className="loginForm-items">
|
||||||
|
|
@ -80,12 +99,16 @@ const LoginFormBody = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="loginForm-actions">
|
<div className="loginForm-actions">
|
||||||
<Field
|
{showHashingGatedOptions && (
|
||||||
label={t('LoginForm.label.savePassword')}
|
<>
|
||||||
name="remember"
|
<Field
|
||||||
component={CheckboxField}
|
label={t('LoginForm.label.savePassword')}
|
||||||
/>
|
name="remember"
|
||||||
<OnChange name="remember">{onRememberChange}</OnChange>
|
component={CheckboxField}
|
||||||
|
/>
|
||||||
|
<OnChange name="remember">{onRememberChange}</OnChange>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button color="primary" onClick={onResetPassword}>
|
<Button color="primary" onClick={onResetPassword}>
|
||||||
{t('LoginForm.label.forgot')}
|
{t('LoginForm.label.forgot')}
|
||||||
|
|
@ -95,31 +118,33 @@ const LoginFormBody = ({
|
||||||
<Field name="selectedHost" component={KnownHosts} />
|
<Field name="selectedHost" component={KnownHosts} />
|
||||||
<OnChange name="selectedHost">{onSelectedHostChange}</OnChange>
|
<OnChange name="selectedHost">{onSelectedHostChange}</OnChange>
|
||||||
</div>
|
</div>
|
||||||
<div className="loginForm-actions">
|
{showHashingGatedOptions && (
|
||||||
<Field name="autoConnect" type="checkbox">
|
<div className="loginForm-actions">
|
||||||
{({ input }) => (
|
<Field name="autoConnect" type="checkbox">
|
||||||
<FormControlLabel
|
{({ input }) => (
|
||||||
className="checkbox-field"
|
<FormControlLabel
|
||||||
label={t('LoginForm.label.autoConnect')}
|
className="checkbox-field"
|
||||||
control={
|
label={t('LoginForm.label.autoConnect')}
|
||||||
<Checkbox
|
control={
|
||||||
className="checkbox-field__box"
|
<Checkbox
|
||||||
checked={!!input.value}
|
className="checkbox-field__box"
|
||||||
onChange={(_e, checked) => onUserToggleAutoConnect(checked, input.onChange)}
|
checked={!!input.value}
|
||||||
color="primary"
|
onChange={(_e, checked) => onUserToggleAutoConnect(checked, input.onChange)}
|
||||||
/>
|
color="primary"
|
||||||
}
|
/>
|
||||||
/>
|
}
|
||||||
)}
|
/>
|
||||||
</Field>
|
)}
|
||||||
</div>
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="loginForm-submit rounded tall"
|
className="loginForm-submit rounded tall"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disableSubmitButton}
|
disabled={loginDisabled}
|
||||||
>
|
>
|
||||||
{t('LoginForm.label.login')}
|
{t('LoginForm.label.login')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
||||||
import { HostDTO } from '@app/services';
|
import { HostDTO } from '@app/services';
|
||||||
|
|
||||||
export interface LoginFormBody {
|
export interface LoginFormBody {
|
||||||
|
selectedHost: HostDTO | undefined;
|
||||||
useStoredPasswordLabel: boolean;
|
useStoredPasswordLabel: boolean;
|
||||||
setUseStoredPasswordLabel: (v: boolean) => void;
|
setUseStoredPasswordLabel: (v: boolean) => void;
|
||||||
onSelectedHostChange: (host: HostDTO | undefined) => void;
|
onSelectedHostChange: (host: HostDTO | undefined) => void;
|
||||||
|
|
@ -35,16 +36,30 @@ export function useLoginFormBody(form: MinimalFormApi): LoginFormBody {
|
||||||
|
|
||||||
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
|
const togglePasswordLabel = (on: boolean) => setUseStoredPasswordLabel(on);
|
||||||
|
|
||||||
// @critical Host-sync must not touch autoConnect — app-level setting, not per-host.
|
// @critical Host-sync normally must not touch autoConnect — it's an app-level
|
||||||
|
// setting, not per-host. The single exception is switching to a host that is
|
||||||
|
// *proven* naked (supportsHashedPassword === false): the Remember/AutoConnect
|
||||||
|
// UI is hidden there, so we also clear the persisted setting to prevent a
|
||||||
|
// stale `true` from surviving silently. `undefined` (fresh host, test not
|
||||||
|
// yet complete) leaves the preference alone — test-connection will resolve
|
||||||
|
// capability in milliseconds.
|
||||||
const onSelectedHostChange = (host: HostDTO | undefined) => {
|
const onSelectedHostChange = (host: HostDTO | undefined) => {
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const nakedServer = host.supportsHashedPassword === false;
|
||||||
form.change('userName', host.userName ?? '');
|
form.change('userName', host.userName ?? '');
|
||||||
form.change('password', '');
|
form.change('password', '');
|
||||||
form.change('remember', Boolean(host.remember));
|
form.change('remember', !nakedServer && Boolean(host.remember));
|
||||||
setStoredHashInvalidated(false);
|
setStoredHashInvalidated(false);
|
||||||
togglePasswordLabel(Boolean(host.remember && host.hashedPassword));
|
togglePasswordLabel(!nakedServer && Boolean(host.remember && host.hashedPassword));
|
||||||
|
|
||||||
|
if (nakedServer) {
|
||||||
|
form.change('autoConnect', false);
|
||||||
|
if (settings.status === LoadingState.READY && settings.value?.autoConnect) {
|
||||||
|
void settings.update({ autoConnect: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUserNameChange = (userName: string | undefined) => {
|
const onUserNameChange = (userName: string | undefined) => {
|
||||||
|
|
@ -81,6 +96,7 @@ export function useLoginFormBody(form: MinimalFormApi): LoginFormBody {
|
||||||
togglePasswordLabel(canUseStoredPassword(values.remember, values.password));
|
togglePasswordLabel(canUseStoredPassword(values.remember, values.password));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
selectedHost,
|
||||||
useStoredPasswordLabel,
|
useStoredPasswordLabel,
|
||||||
setUseStoredPasswordLabel,
|
setUseStoredPasswordLabel,
|
||||||
onSelectedHostChange,
|
onSelectedHostChange,
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,10 @@ describe('Actions', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('testConnectionSuccessful', () => {
|
it('testConnectionSuccessful', () => {
|
||||||
expect(Actions.testConnectionSuccessful()).toEqual({ type: Types.TEST_CONNECTION_SUCCESSFUL, payload: undefined });
|
expect(Actions.testConnectionSuccessful({ serverOptions: 1 })).toEqual({
|
||||||
|
type: Types.TEST_CONNECTION_SUCCESSFUL,
|
||||||
|
payload: { serverOptions: 1 },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('testConnectionFailed', () => {
|
it('testConnectionFailed', () => {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ const SignalActions = {
|
||||||
loginSuccessful: createAction<{ options: WebsocketTypes.LoginSuccessContext }>('server/loginSuccessful'),
|
loginSuccessful: createAction<{ options: WebsocketTypes.LoginSuccessContext }>('server/loginSuccessful'),
|
||||||
loginFailed: createAction('server/loginFailed'),
|
loginFailed: createAction('server/loginFailed'),
|
||||||
connectionFailed: createAction('server/connectionFailed'),
|
connectionFailed: createAction('server/connectionFailed'),
|
||||||
testConnectionSuccessful: createAction('server/testConnectionSuccessful'),
|
|
||||||
testConnectionFailed: createAction('server/testConnectionFailed'),
|
|
||||||
registrationRequiresEmail: createAction('server/registrationRequiresEmail'),
|
registrationRequiresEmail: createAction('server/registrationRequiresEmail'),
|
||||||
registrationSuccess: createAction('server/registrationSuccess'),
|
registrationSuccess: createAction('server/registrationSuccess'),
|
||||||
registrationEmailError: createAction<{ error: string }>('server/registrationEmailError'),
|
registrationEmailError: createAction<{ error: string }>('server/registrationEmailError'),
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,10 @@ describe('Dispatch', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('testConnectionSuccessful dispatches Actions.testConnectionSuccessful()', () => {
|
it('testConnectionSuccessful dispatches Actions.testConnectionSuccessful()', () => {
|
||||||
Dispatch.testConnectionSuccessful();
|
Dispatch.testConnectionSuccessful(3);
|
||||||
expect(mockDispatch).toHaveBeenCalledWith(Actions.testConnectionSuccessful());
|
expect(mockDispatch).toHaveBeenCalledWith(
|
||||||
|
Actions.testConnectionSuccessful({ serverOptions: 3 }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('testConnectionFailed dispatches Actions.testConnectionFailed()', () => {
|
it('testConnectionFailed dispatches Actions.testConnectionFailed()', () => {
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,11 @@ export const Dispatch = {
|
||||||
connectionFailed: () => {
|
connectionFailed: () => {
|
||||||
store.dispatch(Actions.connectionFailed());
|
store.dispatch(Actions.connectionFailed());
|
||||||
},
|
},
|
||||||
testConnectionSuccessful: () => {
|
testConnectionStarted: () => {
|
||||||
store.dispatch(Actions.testConnectionSuccessful());
|
store.dispatch(Actions.testConnectionStarted());
|
||||||
|
},
|
||||||
|
testConnectionSuccessful: (serverOptions: number) => {
|
||||||
|
store.dispatch(Actions.testConnectionSuccessful({ serverOptions }));
|
||||||
},
|
},
|
||||||
testConnectionFailed: () => {
|
testConnectionFailed: () => {
|
||||||
store.dispatch(Actions.testConnectionFailed());
|
store.dispatch(Actions.testConnectionFailed());
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { App, Data, Enriched } from '@app/types';
|
import { App, Data, Enriched } from '@app/types';
|
||||||
import { WebsocketTypes } from '@app/websocket/types';
|
import { WebsocketTypes } from '@app/websocket/types';
|
||||||
|
|
||||||
|
export type TestConnectionStatus = 'testing' | 'success' | 'failed' | null;
|
||||||
|
|
||||||
export interface ServerState {
|
export interface ServerState {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
|
/** Lifecycle of the most recent test connection — drives Login button + hashed-password UI gates. */
|
||||||
|
testConnectionStatus: TestConnectionStatus;
|
||||||
/** Buddies keyed by username for O(1) lookup. Use `getSortedBuddyList` for display. */
|
/** Buddies keyed by username for O(1) lookup. Use `getSortedBuddyList` for display. */
|
||||||
buddyList: { [userName: string]: Data.ServerInfo_User };
|
buddyList: { [userName: string]: Data.ServerInfo_User };
|
||||||
/** Ignored users keyed by username for O(1) lookup. Use `getSortedIgnoreList` for display. */
|
/** Ignored users keyed by username for O(1) lookup. Use `getSortedIgnoreList` for display. */
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ function removeByPath(folder: Data.ServerInfo_DeckStorage_Folder, pathSegments:
|
||||||
|
|
||||||
const initialState: ServerState = {
|
const initialState: ServerState = {
|
||||||
initialized: false,
|
initialized: false,
|
||||||
|
testConnectionStatus: null,
|
||||||
buddyList: {},
|
buddyList: {},
|
||||||
ignoreList: {},
|
ignoreList: {},
|
||||||
|
|
||||||
|
|
@ -124,6 +125,22 @@ export const serverSlice = createSlice({
|
||||||
state.status.connectionAttemptMade = true;
|
state.status.connectionAttemptMade = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
testConnectionStarted: (state) => {
|
||||||
|
state.testConnectionStatus = 'testing';
|
||||||
|
},
|
||||||
|
|
||||||
|
// `serverOptions` is typed on the action so `useReduxEffect` subscribers
|
||||||
|
// (see useKnownHostsComponent) can read it from the dispatched action —
|
||||||
|
// it's deliberately not stored in state since only the lifecycle matters
|
||||||
|
// here; the capability bitmask is persisted per-host to Dexie.
|
||||||
|
testConnectionSuccessful: (state, _action: PayloadAction<{ serverOptions: number }>) => {
|
||||||
|
state.testConnectionStatus = 'success';
|
||||||
|
},
|
||||||
|
|
||||||
|
testConnectionFailed: (state) => {
|
||||||
|
state.testConnectionStatus = 'failed';
|
||||||
|
},
|
||||||
|
|
||||||
clearStore: (state) => ({
|
clearStore: (state) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
status: { ...state.status },
|
status: { ...state.status },
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export const Selectors = {
|
||||||
getDescription: ({ server }: State) => server.status.description,
|
getDescription: ({ server }: State) => server.status.description,
|
||||||
getState: ({ server }: State) => server.status.state,
|
getState: ({ server }: State) => server.status.state,
|
||||||
getConnectionAttemptMade: ({ server }: State) => server.status.connectionAttemptMade,
|
getConnectionAttemptMade: ({ server }: State) => server.status.connectionAttemptMade,
|
||||||
|
/** Lifecycle status of the latest test connection. `null` until the first test fires. */
|
||||||
|
getTestConnectionStatus: ({ server }: State) => server.testConnectionStatus,
|
||||||
getUser: ({ server }: State) => server.user,
|
getUser: ({ server }: State) => server.user,
|
||||||
|
|
||||||
/** True when the server status has reached LOGGED_IN. */
|
/** True when the server status has reached LOGGED_IN. */
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export const Types = {
|
||||||
LOGIN_SUCCESSFUL: a.loginSuccessful.type,
|
LOGIN_SUCCESSFUL: a.loginSuccessful.type,
|
||||||
LOGIN_FAILED: a.loginFailed.type,
|
LOGIN_FAILED: a.loginFailed.type,
|
||||||
CONNECTION_FAILED: a.connectionFailed.type,
|
CONNECTION_FAILED: a.connectionFailed.type,
|
||||||
|
TEST_CONNECTION_STARTED: a.testConnectionStarted.type,
|
||||||
TEST_CONNECTION_SUCCESSFUL: a.testConnectionSuccessful.type,
|
TEST_CONNECTION_SUCCESSFUL: a.testConnectionSuccessful.type,
|
||||||
TEST_CONNECTION_FAILED: a.testConnectionFailed.type,
|
TEST_CONNECTION_FAILED: a.testConnectionFailed.type,
|
||||||
SERVER_MESSAGE: a.serverMessage.type,
|
SERVER_MESSAGE: a.serverMessage.type,
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,8 @@ export class Host {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
hashedPassword?: string;
|
hashedPassword?: string;
|
||||||
remember?: boolean;
|
remember?: boolean;
|
||||||
|
// Captured from Event_ServerIdentification.serverOptions during test connection.
|
||||||
|
// `undefined` = never tested; `true`/`false` = confirmed. UI gates the
|
||||||
|
// Remember Password and Auto Connect checkboxes on this.
|
||||||
|
supportsHashedPassword?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,35 @@ import type { IWebClientResponse } from './types/WebClientResponse';
|
||||||
import type { IWebClientRequest } from './types/WebClientRequest';
|
import type { IWebClientRequest } from './types/WebClientRequest';
|
||||||
import type { ConnectTarget } from './types/WebClientConfig';
|
import type { ConnectTarget } from './types/WebClientConfig';
|
||||||
import { installMockWebSocket } from './__mocks__/helpers';
|
import { installMockWebSocket } from './__mocks__/helpers';
|
||||||
|
import { create, setExtension, toBinary } from '@bufbuild/protobuf';
|
||||||
|
import {
|
||||||
|
Event_ServerIdentification_ext,
|
||||||
|
Event_ServerIdentification_ServerOptions,
|
||||||
|
Event_ServerIdentificationSchema,
|
||||||
|
ServerMessageSchema,
|
||||||
|
ServerMessage_MessageType,
|
||||||
|
SessionEventSchema,
|
||||||
|
} from '@app/generated';
|
||||||
|
import { PROTOCOL_VERSION } from './config';
|
||||||
|
|
||||||
|
function buildServerIdentificationMessage({
|
||||||
|
protocolVersion = PROTOCOL_VERSION,
|
||||||
|
serverOptions = 0,
|
||||||
|
}: { protocolVersion?: number; serverOptions?: number } = {}): Uint8Array {
|
||||||
|
const ident = create(Event_ServerIdentificationSchema, {
|
||||||
|
serverName: 'TestServer',
|
||||||
|
serverVersion: '2.8.0',
|
||||||
|
protocolVersion,
|
||||||
|
serverOptions,
|
||||||
|
});
|
||||||
|
const sessionEvent = create(SessionEventSchema);
|
||||||
|
setExtension(sessionEvent, Event_ServerIdentification_ext, ident);
|
||||||
|
const server = create(ServerMessageSchema, {
|
||||||
|
messageType: ServerMessage_MessageType.SESSION_EVENT,
|
||||||
|
sessionEvent,
|
||||||
|
});
|
||||||
|
return toBinary(ServerMessageSchema, server);
|
||||||
|
}
|
||||||
|
|
||||||
function makeMockResponse(): IWebClientResponse {
|
function makeMockResponse(): IWebClientResponse {
|
||||||
return {
|
return {
|
||||||
|
|
@ -171,23 +200,49 @@ describe('WebClient', () => {
|
||||||
expect(MockWS).toHaveBeenCalledWith(expect.stringContaining('://h:1'));
|
expect(MockWS).toHaveBeenCalledWith(expect.stringContaining('://h:1'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls testConnectionSuccessful and closes on open', () => {
|
it('routes path-bearing hosts through the default TLS port (nginx proxy)', () => {
|
||||||
|
client.testConnect({ host: 'server.example.com/servatrice', port: '4748' });
|
||||||
|
expect(MockWS).toHaveBeenCalledWith(expect.stringMatching(/:\/\/server\.example\.com\/servatrice$/));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches testConnectionSuccessful with serverOptions on ServerIdentification', () => {
|
||||||
client.testConnect(target);
|
client.testConnect(target);
|
||||||
wsMockInstance.onopen();
|
const data = buildServerIdentificationMessage({
|
||||||
expect(mockResponse.session.testConnectionSuccessful).toHaveBeenCalled();
|
serverOptions: Event_ServerIdentification_ServerOptions.SupportsPasswordHash,
|
||||||
|
});
|
||||||
|
wsMockInstance.onmessage({ data: data.buffer });
|
||||||
|
expect(mockResponse.session.testConnectionSuccessful).toHaveBeenCalledWith(
|
||||||
|
Event_ServerIdentification_ServerOptions.SupportsPasswordHash,
|
||||||
|
);
|
||||||
expect(wsMockInstance.close).toHaveBeenCalled();
|
expect(wsMockInstance.close).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports success with serverOptions=0 for naked-password servers', () => {
|
||||||
|
client.testConnect(target);
|
||||||
|
const data = buildServerIdentificationMessage({ serverOptions: 0 });
|
||||||
|
wsMockInstance.onmessage({ data: data.buffer });
|
||||||
|
expect(mockResponse.session.testConnectionSuccessful).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on protocol-version mismatch instead of reporting success', () => {
|
||||||
|
client.testConnect(target);
|
||||||
|
const data = buildServerIdentificationMessage({ protocolVersion: PROTOCOL_VERSION + 1 });
|
||||||
|
wsMockInstance.onmessage({ data: data.buffer });
|
||||||
|
expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled();
|
||||||
|
expect(mockResponse.session.testConnectionSuccessful).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('calls testConnectionFailed on error', () => {
|
it('calls testConnectionFailed on error', () => {
|
||||||
client.testConnect(target);
|
client.testConnect(target);
|
||||||
wsMockInstance.onerror();
|
wsMockInstance.onerror();
|
||||||
expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled();
|
expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes socket after keepalive timeout', () => {
|
it('fires testConnectionFailed when ServerIdentification never arrives before the keepalive timeout', () => {
|
||||||
client.testConnect(target);
|
client.testConnect(target);
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
expect(wsMockInstance.close).toHaveBeenCalled();
|
expect(wsMockInstance.close).toHaveBeenCalled();
|
||||||
|
expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes the prior in-flight socket on rapid re-click', () => {
|
it('closes the prior in-flight socket on rapid re-click', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
|
import { fromBinary, getExtension, hasExtension } from '@bufbuild/protobuf';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Event_ServerIdentification_ext,
|
||||||
|
ServerMessageSchema,
|
||||||
|
ServerMessage_MessageType,
|
||||||
|
} from '@app/generated';
|
||||||
|
|
||||||
import { ping } from './commands/session';
|
import { ping } from './commands/session';
|
||||||
import { CLIENT_OPTIONS } from './config';
|
import { CLIENT_OPTIONS, PROTOCOL_VERSION } from './config';
|
||||||
import { GameEvents } from './events/game';
|
import { GameEvents } from './events/game';
|
||||||
import { RoomEvents } from './events/room';
|
import { RoomEvents } from './events/room';
|
||||||
import { SessionEvents } from './events/session';
|
import { SessionEvents } from './events/session';
|
||||||
|
|
@ -9,6 +17,7 @@ import type { IWebClientResponse } from './types/WebClientResponse';
|
||||||
import { StatusEnum } from './types/StatusEnum';
|
import { StatusEnum } from './types/StatusEnum';
|
||||||
import { ProtobufService } from './services/ProtobufService';
|
import { ProtobufService } from './services/ProtobufService';
|
||||||
import { WebSocketService } from './services/WebSocketService';
|
import { WebSocketService } from './services/WebSocketService';
|
||||||
|
import { buildWebSocketUrl } from './utils/buildWebSocketUrl';
|
||||||
|
|
||||||
export class WebClient {
|
export class WebClient {
|
||||||
private static _instance: WebClient | null = null;
|
private static _instance: WebClient | null = null;
|
||||||
|
|
@ -82,34 +91,58 @@ export class WebClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocol = window.location.hostname === 'localhost' ? 'ws' : 'wss';
|
const protocol = window.location.hostname === 'localhost' ? 'ws' : 'wss';
|
||||||
const { host, port } = target;
|
const socket = new WebSocket(buildWebSocketUrl(protocol, target.host, target.port));
|
||||||
const socket = new WebSocket(`${protocol}://${host}:${port}`);
|
|
||||||
socket.binaryType = 'arraybuffer';
|
socket.binaryType = 'arraybuffer';
|
||||||
this.testSocket = socket;
|
this.testSocket = socket;
|
||||||
|
|
||||||
const timeout = setTimeout(() => socket.close(), CLIENT_OPTIONS.keepalive);
|
// "Green" means reachable AND speaking a compatible Cockatrice protocol.
|
||||||
|
// Waiting for Event_ServerIdentification lets us carry serverOptions back
|
||||||
const clearIfActive = () => {
|
// to the UI so naked-password hosts can be distinguished without a login.
|
||||||
|
let resolved = false;
|
||||||
|
const resolve = (ok: boolean, serverOptions = 0): void => {
|
||||||
|
if (resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
// Suppress dispatches from a superseded socket — a newer test has
|
||||||
|
// already taken over and we'd race a stale result into its pending-ref.
|
||||||
if (this.testSocket === socket) {
|
if (this.testSocket === socket) {
|
||||||
|
if (ok) {
|
||||||
|
this.response.session.testConnectionSuccessful(serverOptions);
|
||||||
|
} else {
|
||||||
|
this.response.session.testConnectionFailed();
|
||||||
|
}
|
||||||
this.testSocket = null;
|
this.testSocket = null;
|
||||||
}
|
}
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => resolve(false), CLIENT_OPTIONS.keepalive);
|
||||||
|
|
||||||
|
socket.onmessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const msg = fromBinary(ServerMessageSchema, new Uint8Array(event.data));
|
||||||
|
if (msg.messageType !== ServerMessage_MessageType.SESSION_EVENT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sessionEvent = msg.sessionEvent;
|
||||||
|
if (!sessionEvent || !hasExtension(sessionEvent, Event_ServerIdentification_ext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ident = getExtension(sessionEvent, Event_ServerIdentification_ext);
|
||||||
|
if (ident.protocolVersion !== PROTOCOL_VERSION) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(true, ident.serverOptions);
|
||||||
|
} catch {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onerror = () => resolve(false);
|
||||||
clearTimeout(timeout);
|
socket.onclose = () => resolve(false);
|
||||||
this.response.session.testConnectionSuccessful();
|
|
||||||
socket.close();
|
|
||||||
clearIfActive();
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onerror = () => {
|
|
||||||
this.response.session.testConnectionFailed();
|
|
||||||
clearIfActive();
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
clearIfActive();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public disconnect(): void {
|
public disconnect(): void {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,12 @@ export function serverIdentification(info: Event_ServerIdentification): void {
|
||||||
SessionCommands.updateStatus(StatusEnum.LOGGING_IN, 'Logging In...');
|
SessionCommands.updateStatus(StatusEnum.LOGGING_IN, 'Logging In...');
|
||||||
if (getPasswordSalt) {
|
if (getPasswordSalt) {
|
||||||
SessionCommands.requestPasswordSalt(rest,
|
SessionCommands.requestPasswordSalt(rest,
|
||||||
(salt) => SessionCommands.login(rest, password, salt),
|
// Empty salt means the server advertised SupportsPasswordHash but
|
||||||
|
// can't actually produce one. Treat it as effectively unsupported —
|
||||||
|
// fall through to a plain-password login rather than failing.
|
||||||
|
(salt) => salt
|
||||||
|
? SessionCommands.login(rest, password, salt)
|
||||||
|
: SessionCommands.login(rest, password),
|
||||||
() => {
|
() => {
|
||||||
response.session.loginFailed(); SessionCommands.disconnect();
|
response.session.loginFailed(); SessionCommands.disconnect();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
// Integration coverage of the full login chain through the real session
|
||||||
|
// event handler + real session commands. Exercises:
|
||||||
|
// serverIdentification(info)
|
||||||
|
// → requestPasswordSalt (real)
|
||||||
|
// → login (real)
|
||||||
|
// → WebClient.instance.response.session.loginSuccessful(...)
|
||||||
|
//
|
||||||
|
// Only the transport (sendSessionCommand), the connection-state shim, and the
|
||||||
|
// password-hash bitmask helper are mocked — everything in between is the
|
||||||
|
// production code path. This triangulates the "loginSuccessful payload is
|
||||||
|
// empty" symptom reported in plans/when-the-login-quirky-crane.md.
|
||||||
|
|
||||||
|
vi.mock('../../WebClient');
|
||||||
|
|
||||||
|
vi.mock('../../config', () => ({
|
||||||
|
CLIENT_CONFIG: { clientver: 'test-client', clientfeatures: [] },
|
||||||
|
CLIENT_OPTIONS: { autojoinrooms: false, keepalive: 5000 },
|
||||||
|
PROTOCOL_VERSION: 14,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../utils/connectionState', () => ({
|
||||||
|
consumePendingOptions: vi.fn().mockReturnValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Only the bitmask check is mocked; hashPassword stays real so the test
|
||||||
|
// asserts against a genuinely computed hash (not a sentinel string).
|
||||||
|
vi.mock('../../utils', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../../utils')>()),
|
||||||
|
passwordSaltSupported: vi.fn().mockReturnValue(0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { Mock } from 'vitest';
|
||||||
|
import { Event_ServerIdentificationSchema } from '@app/generated';
|
||||||
|
|
||||||
|
import { WebClient } from '../../WebClient';
|
||||||
|
import { consumePendingOptions } from '../../utils/connectionState';
|
||||||
|
import { passwordSaltSupported, hashPassword } from '../../utils';
|
||||||
|
import { WebSocketConnectReason } from '../../types/ConnectOptions';
|
||||||
|
import { makeCallbackHelpers } from '../../__mocks__/callbackHelpers';
|
||||||
|
import { serverIdentification } from './serverIdentification';
|
||||||
|
|
||||||
|
const { invokeOnSuccess } = makeCallbackHelpers(
|
||||||
|
WebClient.instance.protobuf.sendSessionCommand as Mock,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeLoginOptions = () => ({
|
||||||
|
host: 'h',
|
||||||
|
port: '1',
|
||||||
|
userName: 'alice',
|
||||||
|
password: 'mypass',
|
||||||
|
reason: WebSocketConnectReason.LOGIN as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeInfo = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
create(Event_ServerIdentificationSchema, {
|
||||||
|
serverName: 'TestServer',
|
||||||
|
serverVersion: '1.0',
|
||||||
|
protocolVersion: 14,
|
||||||
|
serverOptions: 1,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(WebClient.instance.protobuf.sendSessionCommand as Mock).mockClear();
|
||||||
|
(WebClient.instance.response.session.loginSuccessful as Mock).mockClear();
|
||||||
|
(WebClient.instance.response.session.loginFailed as Mock).mockClear();
|
||||||
|
(WebClient.instance.response.session.updateStatus as Mock).mockClear();
|
||||||
|
(consumePendingOptions as Mock).mockReset();
|
||||||
|
(passwordSaltSupported as Mock).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('integration: fresh login on a hashed-password server', () => {
|
||||||
|
it('loginSuccessful carries a non-empty hashedPassword matching hashPassword(salt, password)', () => {
|
||||||
|
(consumePendingOptions as Mock).mockReturnValue(makeLoginOptions());
|
||||||
|
(passwordSaltSupported as Mock).mockReturnValue(1);
|
||||||
|
|
||||||
|
// 1. Server identifies with the hashed-password bit set.
|
||||||
|
serverIdentification(makeInfo({ serverOptions: 1 }));
|
||||||
|
|
||||||
|
// 2. RequestPasswordSalt command was sent; resolve it with a salt.
|
||||||
|
invokeOnSuccess({ passwordSalt: 'xyz' });
|
||||||
|
|
||||||
|
// 3. Login command was sent; resolve it with a minimal success response.
|
||||||
|
invokeOnSuccess({ buddyList: [], ignoreList: [], userInfo: { name: 'alice' } });
|
||||||
|
|
||||||
|
// 4. The response layer must receive the SAME hash the client computed.
|
||||||
|
const expected = hashPassword('xyz', 'mypass');
|
||||||
|
expect(WebClient.instance.response.session.loginSuccessful).toHaveBeenCalledTimes(1);
|
||||||
|
expect(WebClient.instance.response.session.loginSuccessful).toHaveBeenCalledWith({
|
||||||
|
hashedPassword: expected,
|
||||||
|
});
|
||||||
|
expect(expected.length).toBeGreaterThan(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Command_Login carries hashedPassword and no plaintext password value', () => {
|
||||||
|
(consumePendingOptions as Mock).mockReturnValue(makeLoginOptions());
|
||||||
|
(passwordSaltSupported as Mock).mockReturnValue(1);
|
||||||
|
|
||||||
|
serverIdentification(makeInfo({ serverOptions: 1 }));
|
||||||
|
invokeOnSuccess({ passwordSalt: 'xyz' });
|
||||||
|
|
||||||
|
// Second sendSessionCommand call is the Command_Login. Protobuf always
|
||||||
|
// materializes the `password` field (proto-default ""), so we assert on
|
||||||
|
// the VALUE, not the presence: hashedPassword holds the real hash and
|
||||||
|
// plaintext password is empty.
|
||||||
|
const calls = (WebClient.instance.protobuf.sendSessionCommand as Mock).mock.calls;
|
||||||
|
const loginPayload = calls[1]?.[1];
|
||||||
|
expect(loginPayload.hashedPassword).toBe(hashPassword('xyz', 'mypass'));
|
||||||
|
expect(loginPayload.password).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('integration: server returns empty passwordSalt (effectively unsupported)', () => {
|
||||||
|
it('falls back to a plain-password Command_Login', () => {
|
||||||
|
(consumePendingOptions as Mock).mockReturnValue(makeLoginOptions());
|
||||||
|
(passwordSaltSupported as Mock).mockReturnValue(1);
|
||||||
|
|
||||||
|
serverIdentification(makeInfo({ serverOptions: 1 }));
|
||||||
|
// Server's Response_PasswordSalt has the proto default "" — simulate that.
|
||||||
|
invokeOnSuccess({ passwordSalt: '' });
|
||||||
|
|
||||||
|
const calls = (WebClient.instance.protobuf.sendSessionCommand as Mock).mock.calls;
|
||||||
|
// Two commands: RequestPasswordSalt, then the fallback plain Command_Login.
|
||||||
|
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const loginPayload = calls[1][1];
|
||||||
|
expect(loginPayload.password).toBe('mypass');
|
||||||
|
expect(loginPayload.hashedPassword).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not surface as a login failure', () => {
|
||||||
|
(consumePendingOptions as Mock).mockReturnValue(makeLoginOptions());
|
||||||
|
(passwordSaltSupported as Mock).mockReturnValue(1);
|
||||||
|
|
||||||
|
serverIdentification(makeInfo({ serverOptions: 1 }));
|
||||||
|
invokeOnSuccess({ passwordSalt: '' });
|
||||||
|
|
||||||
|
// Missing salt is treated as "unsupported", not a failure — the client
|
||||||
|
// should let the plain login proceed without dispatching loginFailed.
|
||||||
|
expect(WebClient.instance.response.session.loginFailed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,7 @@ import { StatusEnum } from '../types/StatusEnum';
|
||||||
import { KeepAliveService } from './KeepAliveService';
|
import { KeepAliveService } from './KeepAliveService';
|
||||||
import { CLIENT_OPTIONS } from '../config';
|
import { CLIENT_OPTIONS } from '../config';
|
||||||
import type { ConnectTarget } from '../types/WebClientConfig';
|
import type { ConnectTarget } from '../types/WebClientConfig';
|
||||||
|
import { buildWebSocketUrl } from '../utils/buildWebSocketUrl';
|
||||||
|
|
||||||
export interface ReconnectConfig {
|
export interface ReconnectConfig {
|
||||||
maxAttempts: number;
|
maxAttempts: number;
|
||||||
|
|
@ -79,7 +80,7 @@ export class WebSocketService {
|
||||||
this.keepalive = CLIENT_OPTIONS.keepalive;
|
this.keepalive = CLIENT_OPTIONS.keepalive;
|
||||||
|
|
||||||
const { host, port } = target;
|
const { host, port } = target;
|
||||||
this.socket = this.createWebSocket(`${protocol}://${host}:${port}`);
|
this.socket = this.createWebSocket(buildWebSocketUrl(protocol as 'ws' | 'wss', host, port));
|
||||||
}
|
}
|
||||||
|
|
||||||
public disconnect(): void {
|
public disconnect(): void {
|
||||||
|
|
@ -204,7 +205,9 @@ export class WebSocketService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { host, port } = this.lastTarget;
|
const { host, port } = this.lastTarget;
|
||||||
this.socket = this.createWebSocket(`${this.lastProtocol}://${host}:${port}`);
|
this.socket = this.createWebSocket(
|
||||||
|
buildWebSocketUrl(this.lastProtocol as 'ws' | 'wss', host, port),
|
||||||
|
);
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export interface ISessionResponse {
|
||||||
loginSuccessful(options: LoginSuccessContext): void;
|
loginSuccessful(options: LoginSuccessContext): void;
|
||||||
loginFailed(): void;
|
loginFailed(): void;
|
||||||
connectionFailed(): void;
|
connectionFailed(): void;
|
||||||
testConnectionSuccessful(): void;
|
testConnectionSuccessful(serverOptions: number): void;
|
||||||
testConnectionFailed(): void;
|
testConnectionFailed(): void;
|
||||||
updateBuddyList(buddyList: ServerInfo_User[]): void;
|
updateBuddyList(buddyList: ServerInfo_User[]): void;
|
||||||
addToBuddyList(user: ServerInfo_User): void;
|
addToBuddyList(user: ServerInfo_User): void;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from './buildWebSocketUrl';
|
||||||
export * from './guid.util';
|
export * from './guid.util';
|
||||||
export * from './sanitizeHtml.util';
|
export * from './sanitizeHtml.util';
|
||||||
export * from './passwordHasher';
|
export * from './passwordHasher';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
vi.mock('../../generated/proto/event_server_identification_pb', async (importOriginal) => ({
|
vi.mock('../../generated/proto/event_server_identification_pb', async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import('../../generated/proto/event_server_identification_pb')>()),
|
...(await importOriginal<typeof import('../../generated/proto/event_server_identification_pb')>()),
|
||||||
Event_ServerIdentification_ServerOptions: { SupportsPasswordHash: 2 },
|
Event_ServerIdentification_ServerOptions: { SupportsPasswordHash: 1 },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { hashPassword, generateSalt, passwordSaltSupported } from './passwordHasher';
|
import { hashPassword, generateSalt, passwordSaltSupported } from './passwordHasher';
|
||||||
|
|
@ -40,13 +40,19 @@ describe('generateSalt', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('passwordSaltSupported', () => {
|
describe('passwordSaltSupported', () => {
|
||||||
it('returns non-zero when SupportsPasswordHash bit is set', () => {
|
it('returns false for NoOptions (0)', () => {
|
||||||
// SupportsPasswordHash = 2 from mock; 2 & 2 = 2
|
expect(passwordSaltSupported(0)).toBeFalsy();
|
||||||
expect(passwordSaltSupported(2)).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns zero when SupportsPasswordHash bit is not set', () => {
|
it('returns true when the SupportsPasswordHash bit (1) is set', () => {
|
||||||
// 1 & 2 = 0
|
expect(passwordSaltSupported(1)).toBeTruthy();
|
||||||
expect(passwordSaltSupported(1)).toBeFalsy();
|
});
|
||||||
|
|
||||||
|
it('returns false when an unrelated bit is set (2)', () => {
|
||||||
|
expect(passwordSaltSupported(2)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when bit 0 is set alongside other bits (3)', () => {
|
||||||
|
expect(passwordSaltSupported(3)).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue