Files
TREK/client/src/components/Admin/useAdminUserActions.test.ts
T
Maurice b53130ca0d feat(admin): remove the SSO link of an account whose provider changed its subject
A sign-in with another subject of the same provider is refused, so an
account whose provider re-issued the subject (a migrated or rebuilt IdP, a
changed sub format) was locked out: editing the email, as the wiki advised,
never touched the stored link. The edit dialog now offers "Remove SSO link"
for other accounts while SSO is set up (DELETE /api/admin/users/:id/sso,
audit admin.user_sso_unlink). The next SSO sign-in links again under the
rules of a first one, so it still needs a verified, not self-set email.
The log line names the remedy, and the wiki tells the two refusals apart.

Fixes #3065
2026-11-12 15:39:21 +01:00

133 lines
5.1 KiB
TypeScript

import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAdminUserActions } from './useAdminUserActions';
// FE-HOOK-ADMUSERACT-001 to FE-HOOK-ADMUSERACT-009
const api = vi.hoisted(() => ({
rotateJwtSecret: vi.fn(),
resetUserPasskeys: vi.fn(),
resetUserMfa: vi.fn(),
unlinkUserSso: vi.fn(),
}));
vi.mock('../../api/client', () => ({ adminApi: api }));
const t = (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key);
function host() {
return {
toast: { success: vi.fn(), error: vi.fn() },
setRotatingJwt: vi.fn(),
setShowRotateJwtModal: vi.fn(),
logout: vi.fn(),
navigate: vi.fn(),
};
}
function mount(admin = host()) {
return { admin, actions: renderHook(() => useAdminUserActions(admin, t)).result.current };
}
beforeEach(() => {
api.rotateJwtSecret.mockReset().mockResolvedValue({});
api.resetUserPasskeys.mockReset().mockResolvedValue({ deleted: 2 });
api.resetUserMfa.mockReset().mockResolvedValue({ success: true });
api.unlinkUserSso.mockReset().mockResolvedValue({ success: true, unlinked: true });
});
describe('useAdminUserActions', () => {
it('FE-HOOK-ADMUSERACT-001: rotating the secret closes the dialog, logs out and goes to the login page', async () => {
const { admin, actions } = mount();
await act(() => actions.rotateJwt());
expect(admin.setRotatingJwt).toHaveBeenCalledWith(true);
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(false);
expect(admin.logout).toHaveBeenCalledTimes(1);
expect(admin.navigate).toHaveBeenCalledWith('/login', { state: { noRedirect: true } });
expect(admin.logout.mock.invocationCallOrder[0]).toBeLessThan(admin.navigate.mock.invocationCallOrder[0]);
});
it('FE-HOOK-ADMUSERACT-002: a failed rotation toasts, stays signed in and clears the busy state', async () => {
api.rotateJwtSecret.mockRejectedValue(new Error('x'));
const { admin, actions } = mount();
await act(() => actions.rotateJwt());
expect(admin.toast.error).toHaveBeenCalledWith('common.error');
expect(admin.setRotatingJwt.mock.calls).toEqual([[true], [false]]);
expect(admin.logout).not.toHaveBeenCalled();
expect(admin.setShowRotateJwtModal).not.toHaveBeenCalled();
});
it('FE-HOOK-ADMUSERACT-003: a passkey reset toasts the count and reports success', async () => {
const { admin, actions } = mount();
let ok: boolean | undefined;
await act(async () => {
ok = await actions.resetPasskeys({ id: 7 });
});
expect(api.resetUserPasskeys).toHaveBeenCalledWith(7);
expect(admin.toast.success).toHaveBeenCalledWith('admin.passkey.resetDone {"count":2}');
expect(ok).toBe(true);
});
it('FE-HOOK-ADMUSERACT-004: an answer without a count reads as zero', async () => {
api.resetUserPasskeys.mockResolvedValue({});
const { admin, actions } = mount();
await act(() => actions.resetPasskeys({ id: 7 }));
expect(admin.toast.success).toHaveBeenCalledWith('admin.passkey.resetDone {"count":0}');
});
it('FE-HOOK-ADMUSERACT-005: a failed passkey reset toasts and reports failure', async () => {
api.resetUserPasskeys.mockRejectedValue(new Error('x'));
const { admin, actions } = mount();
let ok: boolean | undefined;
await act(async () => {
ok = await actions.resetPasskeys({ id: 7 });
});
expect(admin.toast.error).toHaveBeenCalledWith('common.error');
expect(ok).toBe(false);
});
it('FE-HOOK-ADMUSERACT-006: a 2FA reset calls the API, toasts and reports success', async () => {
const { admin, actions } = mount();
let ok: boolean | undefined;
await act(async () => {
ok = await actions.resetMfa({ id: 9 });
});
expect(api.resetUserMfa).toHaveBeenCalledWith(9);
expect(admin.toast.success).toHaveBeenCalledWith('admin.mfa.resetDone');
expect(ok).toBe(true);
});
it('FE-HOOK-ADMUSERACT-007: a failed 2FA reset toasts and reports failure', async () => {
api.resetUserMfa.mockRejectedValue(new Error('x'));
const { admin, actions } = mount();
let ok: boolean | undefined;
await act(async () => {
ok = await actions.resetMfa({ id: 9 });
});
expect(admin.toast.error).toHaveBeenCalledWith('common.error');
expect(ok).toBe(false);
});
it('FE-HOOK-ADMUSERACT-008: removing the SSO link calls the API, toasts and reports success', async () => {
const { admin, actions } = mount();
let ok: boolean | undefined;
await act(async () => {
ok = await actions.unlinkSso({ id: 11 });
});
expect(api.unlinkUserSso).toHaveBeenCalledWith(11);
expect(admin.toast.success).toHaveBeenCalledWith('admin.sso.unlinkDone');
expect(ok).toBe(true);
});
it('FE-HOOK-ADMUSERACT-009: a failed SSO unlink toasts and reports failure', async () => {
api.unlinkUserSso.mockRejectedValue(new Error('x'));
const { admin, actions } = mount();
let ok: boolean | undefined;
await act(async () => {
ok = await actions.unlinkSso({ id: 11 });
});
expect(admin.toast.error).toHaveBeenCalledWith('common.error');
expect(ok).toBe(false);
});
});