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> = {
|
||||
server: {
|
||||
initialized: false,
|
||||
testConnectionStatus: null,
|
||||
buddyList: {},
|
||||
ignoreList: {},
|
||||
status: {
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ export class SessionResponseImpl implements WebsocketTypes.ISessionResponse {
|
|||
ServerDispatch.connectionFailed();
|
||||
}
|
||||
|
||||
testConnectionSuccessful(): void {
|
||||
ServerDispatch.testConnectionSuccessful();
|
||||
testConnectionSuccessful(serverOptions: number): void {
|
||||
ServerDispatch.testConnectionSuccessful(serverOptions);
|
||||
}
|
||||
|
||||
testConnectionFailed(): void {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => {
|
|||
const {
|
||||
hosts,
|
||||
selectedHost,
|
||||
testingConnection,
|
||||
testConnectionStatus,
|
||||
dialogState,
|
||||
onPick,
|
||||
openAddKnownHostDialog,
|
||||
|
|
@ -119,8 +119,8 @@ const KnownHosts = ({ input, meta, disabled }: KnownHostsProps) => {
|
|||
<MenuItem value={host.id} key={host.id}>
|
||||
<div className="KnownHosts-item">
|
||||
<div className="KnownHosts-item__wrapper">
|
||||
<div className={`KnownHosts-item__status ${testingConnection ?? ''}`}>
|
||||
{testingConnection === TestConnection.FAILED ? (
|
||||
<div className={`KnownHosts-item__status ${testConnectionStatus ?? ''}`}>
|
||||
{testConnectionStatus === TestConnection.FAILED ? (
|
||||
<PortableWifiOffIcon fontSize="small" />
|
||||
) : (
|
||||
<WifiTetheringIcon fontSize="small" />
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useToast } from '@app/components';
|
||||
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
|
||||
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 { passwordSaltSupported } from '@app/websocket';
|
||||
|
||||
export enum TestConnection {
|
||||
TESTING = 'testing',
|
||||
|
|
@ -16,7 +17,7 @@ export enum TestConnection {
|
|||
export interface KnownHostsComponent {
|
||||
hosts: App.Host[];
|
||||
selectedHost: App.Host | undefined;
|
||||
testingConnection: TestConnection | null;
|
||||
testConnectionStatus: TestConnection | null;
|
||||
dialogState: { open: boolean; edit: HostDTO | null };
|
||||
onPick: (id: number) => Promise<void>;
|
||||
openAddKnownHostDialog: () => void;
|
||||
|
|
@ -55,9 +56,13 @@ export function useKnownHostsComponent({
|
|||
edit: null,
|
||||
});
|
||||
|
||||
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
|
||||
// Tracks the host currently awaiting a testConnection response. If null when a
|
||||
// response arrives, the caller has moved on — ignore the stale reply.
|
||||
// UI status lives in redux (see ServerSelectors.getTestConnectionStatus) so
|
||||
// the LoginForm can gate its submit button + hashing-capability UI on the
|
||||
// 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 selectedHost =
|
||||
|
|
@ -66,7 +71,7 @@ export function useKnownHostsComponent({
|
|||
|
||||
const testConnection = (host: HostDTO) => {
|
||||
pendingTestRef.current = host;
|
||||
setTestingConnection(TestConnection.TESTING);
|
||||
ServerDispatch.testConnectionStarted();
|
||||
webClient.request.authentication.testConnection({ ...getHostPort(host) });
|
||||
};
|
||||
|
||||
|
|
@ -78,19 +83,20 @@ export function useKnownHostsComponent({
|
|||
testConnection(selectedHost);
|
||||
}, [selectedHost]);
|
||||
|
||||
useReduxEffect(() => {
|
||||
if (!pendingTestRef.current) {
|
||||
useReduxEffect<{ serverOptions: number }>(({ payload: { serverOptions } }) => {
|
||||
const host = pendingTestRef.current;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
setTestingConnection(TestConnection.SUCCESS);
|
||||
pendingTestRef.current = null;
|
||||
|
||||
const supportsHashedPassword = passwordSaltSupported(serverOptions);
|
||||
if (host.id != null && host.supportsHashedPassword !== supportsHashedPassword) {
|
||||
void knownHosts.update(host.id, { supportsHashedPassword });
|
||||
}
|
||||
}, ServerTypes.TEST_CONNECTION_SUCCESSFUL, []);
|
||||
|
||||
useReduxEffect(() => {
|
||||
if (!pendingTestRef.current) {
|
||||
return;
|
||||
}
|
||||
setTestingConnection(TestConnection.FAILED);
|
||||
pendingTestRef.current = null;
|
||||
}, ServerTypes.TEST_CONNECTION_FAILED, []);
|
||||
|
||||
|
|
@ -163,7 +169,7 @@ export function useKnownHostsComponent({
|
|||
return {
|
||||
hosts,
|
||||
selectedHost,
|
||||
testingConnection,
|
||||
testConnectionStatus,
|
||||
dialogState,
|
||||
onPick,
|
||||
openAddKnownHostDialog,
|
||||
|
|
|
|||
|
|
@ -172,8 +172,13 @@ describe('Login — logout cycle (same JS session)', () => {
|
|||
await flushEffects();
|
||||
first.unmount();
|
||||
|
||||
// Submit button stays disabled until testConnectionStatus resolves to 'success';
|
||||
// preload it so the click actually dispatches.
|
||||
const { getByRole, queryByText } = renderWithProviders(<Login />, {
|
||||
preloadedState: disconnectedState,
|
||||
preloadedState: {
|
||||
...disconnectedState,
|
||||
server: { ...(disconnectedState.server as any), testConnectionStatus: 'success' },
|
||||
},
|
||||
});
|
||||
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], []);
|
||||
|
||||
const updateHost = (
|
||||
hashedPassword: string,
|
||||
hashedPassword: string | undefined,
|
||||
{ selectedHost, remember, userName }: LoginFormValues,
|
||||
) => {
|
||||
if (selectedHost.id == null) {
|
||||
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, {
|
||||
remember,
|
||||
userName: remember ? userName : null,
|
||||
hashedPassword: remember ? hashedPassword : null,
|
||||
remember: persistCredentials,
|
||||
userName: persistCredentials ? userName : null,
|
||||
hashedPassword: persistCredentials ? hashedPassword : null,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ beforeAll(() => {
|
|||
});
|
||||
|
||||
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);
|
||||
|
||||
hoisted.mockUseSettings.mockReturnValue(
|
||||
|
|
@ -44,6 +44,7 @@ describe('LoginForm — regression: settings.autoConnect is not clobbered by hos
|
|||
remember: false,
|
||||
userName: undefined,
|
||||
hashedPassword: undefined,
|
||||
supportsHashedPassword: true,
|
||||
lastSelected: true,
|
||||
});
|
||||
hoisted.mockUseKnownHosts.mockReturnValue(
|
||||
|
|
@ -78,6 +79,7 @@ describe('LoginForm — regression: settings.autoConnect is not clobbered by hos
|
|||
remember: true,
|
||||
userName: 'joe',
|
||||
hashedPassword: 'abc',
|
||||
supportsHashedPassword: true,
|
||||
lastSelected: true,
|
||||
});
|
||||
hoisted.mockUseKnownHosts.mockReturnValue(
|
||||
|
|
@ -95,3 +97,90 @@ describe('LoginForm — regression: settings.autoConnect is not clobbered by hos
|
|||
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 { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
||||
import { HostDTO } from '@app/services';
|
||||
import { ServerSelectors, TestConnectionStatus, useAppSelector } from '@app/store';
|
||||
|
||||
import { useLoginFormBody } from './useLoginForm';
|
||||
|
||||
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 {
|
||||
userName: string;
|
||||
password: string;
|
||||
|
|
@ -47,6 +58,7 @@ const LoginFormBody = ({
|
|||
const STORED_PASSWORD_LABEL = t('LoginForm.label.savedPassword');
|
||||
|
||||
const {
|
||||
selectedHost,
|
||||
useStoredPasswordLabel,
|
||||
setUseStoredPasswordLabel,
|
||||
onSelectedHostChange,
|
||||
|
|
@ -56,6 +68,13 @@ const LoginFormBody = ({
|
|||
passwordFieldBlur,
|
||||
} = 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 (
|
||||
<form className="loginForm" onSubmit={handleSubmit}>
|
||||
<div className="loginForm-items">
|
||||
|
|
@ -80,12 +99,16 @@ const LoginFormBody = ({
|
|||
/>
|
||||
</div>
|
||||
<div className="loginForm-actions">
|
||||
<Field
|
||||
label={t('LoginForm.label.savePassword')}
|
||||
name="remember"
|
||||
component={CheckboxField}
|
||||
/>
|
||||
<OnChange name="remember">{onRememberChange}</OnChange>
|
||||
{showHashingGatedOptions && (
|
||||
<>
|
||||
<Field
|
||||
label={t('LoginForm.label.savePassword')}
|
||||
name="remember"
|
||||
component={CheckboxField}
|
||||
/>
|
||||
<OnChange name="remember">{onRememberChange}</OnChange>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button color="primary" onClick={onResetPassword}>
|
||||
{t('LoginForm.label.forgot')}
|
||||
|
|
@ -95,31 +118,33 @@ const LoginFormBody = ({
|
|||
<Field name="selectedHost" component={KnownHosts} />
|
||||
<OnChange name="selectedHost">{onSelectedHostChange}</OnChange>
|
||||
</div>
|
||||
<div className="loginForm-actions">
|
||||
<Field name="autoConnect" type="checkbox">
|
||||
{({ input }) => (
|
||||
<FormControlLabel
|
||||
className="checkbox-field"
|
||||
label={t('LoginForm.label.autoConnect')}
|
||||
control={
|
||||
<Checkbox
|
||||
className="checkbox-field__box"
|
||||
checked={!!input.value}
|
||||
onChange={(_e, checked) => onUserToggleAutoConnect(checked, input.onChange)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
{showHashingGatedOptions && (
|
||||
<div className="loginForm-actions">
|
||||
<Field name="autoConnect" type="checkbox">
|
||||
{({ input }) => (
|
||||
<FormControlLabel
|
||||
className="checkbox-field"
|
||||
label={t('LoginForm.label.autoConnect')}
|
||||
control={
|
||||
<Checkbox
|
||||
className="checkbox-field__box"
|
||||
checked={!!input.value}
|
||||
onChange={(_e, checked) => onUserToggleAutoConnect(checked, input.onChange)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="loginForm-submit rounded tall"
|
||||
color="primary"
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={disableSubmitButton}
|
||||
disabled={loginDisabled}
|
||||
>
|
||||
{t('LoginForm.label.login')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { LoadingState, useKnownHosts, useSettings } from '@app/hooks';
|
|||
import { HostDTO } from '@app/services';
|
||||
|
||||
export interface LoginFormBody {
|
||||
selectedHost: HostDTO | undefined;
|
||||
useStoredPasswordLabel: boolean;
|
||||
setUseStoredPasswordLabel: (v: boolean) => void;
|
||||
onSelectedHostChange: (host: HostDTO | undefined) => void;
|
||||
|
|
@ -35,16 +36,30 @@ export function useLoginFormBody(form: MinimalFormApi): LoginFormBody {
|
|||
|
||||
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) => {
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
const nakedServer = host.supportsHashedPassword === false;
|
||||
form.change('userName', host.userName ?? '');
|
||||
form.change('password', '');
|
||||
form.change('remember', Boolean(host.remember));
|
||||
form.change('remember', !nakedServer && Boolean(host.remember));
|
||||
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) => {
|
||||
|
|
@ -81,6 +96,7 @@ export function useLoginFormBody(form: MinimalFormApi): LoginFormBody {
|
|||
togglePasswordLabel(canUseStoredPassword(values.remember, values.password));
|
||||
|
||||
return {
|
||||
selectedHost,
|
||||
useStoredPasswordLabel,
|
||||
setUseStoredPasswordLabel,
|
||||
onSelectedHostChange,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,10 @@ describe('Actions', () => {
|
|||
});
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ const SignalActions = {
|
|||
loginSuccessful: createAction<{ options: WebsocketTypes.LoginSuccessContext }>('server/loginSuccessful'),
|
||||
loginFailed: createAction('server/loginFailed'),
|
||||
connectionFailed: createAction('server/connectionFailed'),
|
||||
testConnectionSuccessful: createAction('server/testConnectionSuccessful'),
|
||||
testConnectionFailed: createAction('server/testConnectionFailed'),
|
||||
registrationRequiresEmail: createAction('server/registrationRequiresEmail'),
|
||||
registrationSuccess: createAction('server/registrationSuccess'),
|
||||
registrationEmailError: createAction<{ error: string }>('server/registrationEmailError'),
|
||||
|
|
|
|||
|
|
@ -56,8 +56,10 @@ describe('Dispatch', () => {
|
|||
});
|
||||
|
||||
it('testConnectionSuccessful dispatches Actions.testConnectionSuccessful()', () => {
|
||||
Dispatch.testConnectionSuccessful();
|
||||
expect(mockDispatch).toHaveBeenCalledWith(Actions.testConnectionSuccessful());
|
||||
Dispatch.testConnectionSuccessful(3);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
Actions.testConnectionSuccessful({ serverOptions: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('testConnectionFailed dispatches Actions.testConnectionFailed()', () => {
|
||||
|
|
|
|||
|
|
@ -22,8 +22,11 @@ export const Dispatch = {
|
|||
connectionFailed: () => {
|
||||
store.dispatch(Actions.connectionFailed());
|
||||
},
|
||||
testConnectionSuccessful: () => {
|
||||
store.dispatch(Actions.testConnectionSuccessful());
|
||||
testConnectionStarted: () => {
|
||||
store.dispatch(Actions.testConnectionStarted());
|
||||
},
|
||||
testConnectionSuccessful: (serverOptions: number) => {
|
||||
store.dispatch(Actions.testConnectionSuccessful({ serverOptions }));
|
||||
},
|
||||
testConnectionFailed: () => {
|
||||
store.dispatch(Actions.testConnectionFailed());
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { App, Data, Enriched } from '@app/types';
|
||||
import { WebsocketTypes } from '@app/websocket/types';
|
||||
|
||||
export type TestConnectionStatus = 'testing' | 'success' | 'failed' | null;
|
||||
|
||||
export interface ServerState {
|
||||
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. */
|
||||
buddyList: { [userName: string]: Data.ServerInfo_User };
|
||||
/** 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 = {
|
||||
initialized: false,
|
||||
testConnectionStatus: null,
|
||||
buddyList: {},
|
||||
ignoreList: {},
|
||||
|
||||
|
|
@ -124,6 +125,22 @@ export const serverSlice = createSlice({
|
|||
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) => ({
|
||||
...initialState,
|
||||
status: { ...state.status },
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export const Selectors = {
|
|||
getDescription: ({ server }: State) => server.status.description,
|
||||
getState: ({ server }: State) => server.status.state,
|
||||
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,
|
||||
|
||||
/** True when the server status has reached LOGGED_IN. */
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const Types = {
|
|||
LOGIN_SUCCESSFUL: a.loginSuccessful.type,
|
||||
LOGIN_FAILED: a.loginFailed.type,
|
||||
CONNECTION_FAILED: a.connectionFailed.type,
|
||||
TEST_CONNECTION_STARTED: a.testConnectionStarted.type,
|
||||
TEST_CONNECTION_SUCCESSFUL: a.testConnectionSuccessful.type,
|
||||
TEST_CONNECTION_FAILED: a.testConnectionFailed.type,
|
||||
SERVER_MESSAGE: a.serverMessage.type,
|
||||
|
|
|
|||
|
|
@ -10,4 +10,8 @@ export class Host {
|
|||
userName?: string;
|
||||
hashedPassword?: string;
|
||||
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 { ConnectTarget } from './types/WebClientConfig';
|
||||
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 {
|
||||
return {
|
||||
|
|
@ -171,23 +200,49 @@ describe('WebClient', () => {
|
|||
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);
|
||||
wsMockInstance.onopen();
|
||||
expect(mockResponse.session.testConnectionSuccessful).toHaveBeenCalled();
|
||||
const data = buildServerIdentificationMessage({
|
||||
serverOptions: Event_ServerIdentification_ServerOptions.SupportsPasswordHash,
|
||||
});
|
||||
wsMockInstance.onmessage({ data: data.buffer });
|
||||
expect(mockResponse.session.testConnectionSuccessful).toHaveBeenCalledWith(
|
||||
Event_ServerIdentification_ServerOptions.SupportsPasswordHash,
|
||||
);
|
||||
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', () => {
|
||||
client.testConnect(target);
|
||||
wsMockInstance.onerror();
|
||||
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);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(wsMockInstance.close).toHaveBeenCalled();
|
||||
expect(mockResponse.session.testConnectionFailed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 { CLIENT_OPTIONS } from './config';
|
||||
import { CLIENT_OPTIONS, PROTOCOL_VERSION } from './config';
|
||||
import { GameEvents } from './events/game';
|
||||
import { RoomEvents } from './events/room';
|
||||
import { SessionEvents } from './events/session';
|
||||
|
|
@ -9,6 +17,7 @@ import type { IWebClientResponse } from './types/WebClientResponse';
|
|||
import { StatusEnum } from './types/StatusEnum';
|
||||
import { ProtobufService } from './services/ProtobufService';
|
||||
import { WebSocketService } from './services/WebSocketService';
|
||||
import { buildWebSocketUrl } from './utils/buildWebSocketUrl';
|
||||
|
||||
export class WebClient {
|
||||
private static _instance: WebClient | null = null;
|
||||
|
|
@ -82,34 +91,58 @@ export class WebClient {
|
|||
}
|
||||
|
||||
const protocol = window.location.hostname === 'localhost' ? 'ws' : 'wss';
|
||||
const { host, port } = target;
|
||||
const socket = new WebSocket(`${protocol}://${host}:${port}`);
|
||||
const socket = new WebSocket(buildWebSocketUrl(protocol, target.host, target.port));
|
||||
socket.binaryType = 'arraybuffer';
|
||||
this.testSocket = socket;
|
||||
|
||||
const timeout = setTimeout(() => socket.close(), CLIENT_OPTIONS.keepalive);
|
||||
|
||||
const clearIfActive = () => {
|
||||
// "Green" means reachable AND speaking a compatible Cockatrice protocol.
|
||||
// Waiting for Event_ServerIdentification lets us carry serverOptions back
|
||||
// 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 (ok) {
|
||||
this.response.session.testConnectionSuccessful(serverOptions);
|
||||
} else {
|
||||
this.response.session.testConnectionFailed();
|
||||
}
|
||||
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 = () => {
|
||||
clearTimeout(timeout);
|
||||
this.response.session.testConnectionSuccessful();
|
||||
socket.close();
|
||||
clearIfActive();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
this.response.session.testConnectionFailed();
|
||||
clearIfActive();
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
clearIfActive();
|
||||
};
|
||||
socket.onerror = () => resolve(false);
|
||||
socket.onclose = () => resolve(false);
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,12 @@ export function serverIdentification(info: Event_ServerIdentification): void {
|
|||
SessionCommands.updateStatus(StatusEnum.LOGGING_IN, 'Logging In...');
|
||||
if (getPasswordSalt) {
|
||||
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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 { CLIENT_OPTIONS } from '../config';
|
||||
import type { ConnectTarget } from '../types/WebClientConfig';
|
||||
import { buildWebSocketUrl } from '../utils/buildWebSocketUrl';
|
||||
|
||||
export interface ReconnectConfig {
|
||||
maxAttempts: number;
|
||||
|
|
@ -79,7 +80,7 @@ export class WebSocketService {
|
|||
this.keepalive = CLIENT_OPTIONS.keepalive;
|
||||
|
||||
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 {
|
||||
|
|
@ -204,7 +205,9 @@ export class WebSocketService {
|
|||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export interface ISessionResponse {
|
|||
loginSuccessful(options: LoginSuccessContext): void;
|
||||
loginFailed(): void;
|
||||
connectionFailed(): void;
|
||||
testConnectionSuccessful(): void;
|
||||
testConnectionSuccessful(serverOptions: number): void;
|
||||
testConnectionFailed(): void;
|
||||
updateBuddyList(buddyList: ServerInfo_User[]): void;
|
||||
addToBuddyList(user: ServerInfo_User): void;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './buildWebSocketUrl';
|
||||
export * from './guid.util';
|
||||
export * from './sanitizeHtml.util';
|
||||
export * from './passwordHasher';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
vi.mock('../../generated/proto/event_server_identification_pb', async (importOriginal) => ({
|
||||
...(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';
|
||||
|
|
@ -40,13 +40,19 @@ describe('generateSalt', () => {
|
|||
});
|
||||
|
||||
describe('passwordSaltSupported', () => {
|
||||
it('returns non-zero when SupportsPasswordHash bit is set', () => {
|
||||
// SupportsPasswordHash = 2 from mock; 2 & 2 = 2
|
||||
expect(passwordSaltSupported(2)).toBeTruthy();
|
||||
it('returns false for NoOptions (0)', () => {
|
||||
expect(passwordSaltSupported(0)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('returns zero when SupportsPasswordHash bit is not set', () => {
|
||||
// 1 & 2 = 0
|
||||
expect(passwordSaltSupported(1)).toBeFalsy();
|
||||
it('returns true when the SupportsPasswordHash bit (1) is set', () => {
|
||||
expect(passwordSaltSupported(1)).toBeTruthy();
|
||||
});
|
||||
|
||||
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