mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
with no UI path to onboard new users — the create-user form had zero LDAP
awareness and the only workaround was hand-editing the database.
Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
that returns up to 25 matches via the service-account bind, annotated with
already_provisioned so existing usernames render disabled. Clicking
"Provision user" re-resolves via the service bind and creates the user
through the same _provision_ldap_user helper the auto-provision login path
uses, so group mapping, default-group fallback, and email sync are identical
regardless of which path created the user.
The picker component is shared across all four create-user modal paths
(UsersPage basic + advanced, SettingsPage basic + advanced).
Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
- Open the search connection with check_names=False so ldap3 doesn't reject
the cross-schema OR filter (sAMAccountName/displayName are AD-only)
- Request attributes=["*"] because ldap3's build_attribute_selection
validates each named attribute against the server schema regardless of
check_names, and only the * wildcard is in its hard-coded exclusion list
Login/lookup paths keep check_names=True so typos in user_filter still fail
loudly.
Backend
- New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
by USERS_CREATE; 503 details include ldap3 exception class + message)
- Extract _open_service_connection + _extract_user_info helpers so
authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
bind and attribute-extraction logic
Frontend
- New LdapUserPicker component (debounced search, result list, provision
mutation, already-provisioned guard, error surface)
- Tab toggle wired into UsersPage and SettingsPage modals, plus
CreateUserAdvancedAuthModal props
- 14 i18n keys added to en.ts (other locales fall back to English)
180 lines
6.4 KiB
TypeScript
180 lines
6.4 KiB
TypeScript
/**
|
|
* Tests for LdapUserPicker (#1298).
|
|
*
|
|
* The picker is rendered inside the user-create modal when LDAP is enabled.
|
|
* It owns its own search + provision mutation; the parent modal just provides
|
|
* the onSuccess callback that closes the modal and toasts.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { render } from '../utils';
|
|
import { LdapUserPicker } from '../../components/LdapUserPicker';
|
|
import { api } from '../../api/client';
|
|
|
|
vi.mock('../../api/client', () => ({
|
|
api: {
|
|
searchLDAPDirectory: vi.fn(),
|
|
provisionLDAPUser: vi.fn(),
|
|
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
|
|
getSettings: vi.fn().mockResolvedValue({}),
|
|
},
|
|
}));
|
|
|
|
describe('LdapUserPicker', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('does not search until the user types at least 2 characters', async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<LdapUserPicker onSuccess={() => {}} />);
|
|
|
|
const input = screen.getByPlaceholderText(/type a username/i);
|
|
await user.type(input, 'a');
|
|
|
|
// Advance well past the debounce window — a 1-char query must still not fire.
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(api.searchLDAPDirectory).not.toHaveBeenCalled();
|
|
expect(screen.getByText(/at least 2 characters/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('debounces typing and only sends the final query', async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
(api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
|
|
render(<LdapUserPicker onSuccess={() => {}} />);
|
|
const input = screen.getByPlaceholderText(/type a username/i);
|
|
|
|
await user.type(input, 'jdoe');
|
|
// After the last keystroke, the 300ms debounce hasn't elapsed yet — verify
|
|
// we haven't fired a request for an intermediate value like 'jd' or 'jdo'.
|
|
expect(api.searchLDAPDirectory).not.toHaveBeenCalled();
|
|
|
|
await vi.advanceTimersByTimeAsync(350);
|
|
|
|
await waitFor(() => {
|
|
expect(api.searchLDAPDirectory).toHaveBeenCalledTimes(1);
|
|
expect(api.searchLDAPDirectory).toHaveBeenCalledWith('jdoe');
|
|
});
|
|
});
|
|
|
|
it('renders search results and lets the admin select and provision one', async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
(api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{
|
|
username: 'jdoe',
|
|
email: 'jdoe@example.com',
|
|
display_name: 'John Doe',
|
|
dn: 'cn=John Doe,dc=example,dc=com',
|
|
already_provisioned: false,
|
|
},
|
|
]);
|
|
(api.provisionLDAPUser as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
id: 42,
|
|
username: 'jdoe',
|
|
auth_source: 'ldap',
|
|
groups: [],
|
|
permissions: [],
|
|
role: 'user',
|
|
is_active: true,
|
|
is_admin: false,
|
|
email: 'jdoe@example.com',
|
|
created_at: '2026-05-15T10:00:00Z',
|
|
});
|
|
|
|
const onSuccess = vi.fn();
|
|
render(<LdapUserPicker onSuccess={onSuccess} />);
|
|
|
|
await user.type(screen.getByPlaceholderText(/type a username/i), 'jdoe');
|
|
await vi.advanceTimersByTimeAsync(350);
|
|
|
|
// Result list renders with the username + display name visible.
|
|
const resultRow = await screen.findByText('jdoe');
|
|
expect(resultRow).toBeInTheDocument();
|
|
expect(screen.getByText(/john doe/i)).toBeInTheDocument();
|
|
|
|
await user.click(resultRow);
|
|
|
|
// Submit button activates after selection. The label is "Provision user"
|
|
// — match it specifically so we don't accidentally select the "Provisioning..."
|
|
// loading variant.
|
|
const submit = screen.getByRole('button', { name: /^provision user$/i });
|
|
expect(submit).not.toBeDisabled();
|
|
await user.click(submit);
|
|
|
|
await waitFor(() => {
|
|
expect(api.provisionLDAPUser).toHaveBeenCalledWith('jdoe');
|
|
expect(onSuccess).toHaveBeenCalledTimes(1);
|
|
expect(onSuccess.mock.calls[0][0].username).toBe('jdoe');
|
|
});
|
|
});
|
|
|
|
it('disables already-provisioned rows so admins cannot pick them', async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
(api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{
|
|
username: 'existing',
|
|
email: 'existing@example.com',
|
|
display_name: null,
|
|
dn: 'cn=existing,dc=example,dc=com',
|
|
already_provisioned: true,
|
|
},
|
|
]);
|
|
|
|
render(<LdapUserPicker onSuccess={() => {}} />);
|
|
await user.type(screen.getByPlaceholderText(/type a username/i), 'existing');
|
|
await vi.advanceTimersByTimeAsync(350);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/already provisioned/i)).toBeInTheDocument();
|
|
});
|
|
|
|
// The row's <button> is disabled — userEvent.click will throw, so we just
|
|
// assert the disabled attribute is set, which is the contract that drives
|
|
// the cursor + opacity styling.
|
|
const rowButton = screen.getByText('existing').closest('button')!;
|
|
expect(rowButton).toBeDisabled();
|
|
|
|
// The submit button stays disabled because there's no selectable row.
|
|
const submit = screen.getByRole('button', { name: /^provision user$/i });
|
|
expect(submit).toBeDisabled();
|
|
});
|
|
|
|
it('surfaces provision errors instead of swallowing them', async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
(api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{
|
|
username: 'jdoe',
|
|
email: null,
|
|
display_name: null,
|
|
dn: 'cn=jdoe,dc=example,dc=com',
|
|
already_provisioned: false,
|
|
},
|
|
]);
|
|
(api.provisionLDAPUser as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
new Error('LDAP server unreachable')
|
|
);
|
|
|
|
const onSuccess = vi.fn();
|
|
render(<LdapUserPicker onSuccess={onSuccess} />);
|
|
await user.type(screen.getByPlaceholderText(/type a username/i), 'jdoe');
|
|
await vi.advanceTimersByTimeAsync(350);
|
|
|
|
await user.click(await screen.findByText('jdoe'));
|
|
await user.click(screen.getByRole('button', { name: /^provision user$/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/ldap server unreachable/i)).toBeInTheDocument();
|
|
});
|
|
expect(onSuccess).not.toHaveBeenCalled();
|
|
});
|
|
});
|