Any authenticated user can invite an e-mail address; the mailed single-use token lets exactly one signup through even while registration is closed. Open (pending, unexpired) invitations count against the new instance setting invitations.maxOpenPerUser (default 5, 0 disables inviting) — plus a 20/day per-user rate limit so a revoke-and-recreate loop cannot become a mail cannon. Only the SHA-256 token hash is stored (auth-tokens pattern); a failed signup (taken username) un-redeems the token so the invitee can retry. Surfaces: invitations section in the user settings (list, invite, revoke, quota line; wide table in a focusable .table-scroll region), signup page reads ?invitation=<token> (preview banner, e-mail prefill, closed-mode gate opens only for a previewed-valid token), admin general card gets the quota field (flat RHF name per #322; VS-NfD marked and hideable). Governance: audit actions invitation.created/revoked/accepted (catalogue 1.10), VS-NfD profile entry (compliant: 0) + hardening-guide row, i18n de+en including the invitation mail template. Tests: api e2e-db (mail link, closed-mode single-use signup with un-redeem on failure, quota + revoke frees slot, quota 0 = 403, auth matrix), new web e2e pack invitations.spec.ts (full UI loop through Mailpit, wired into ci.yml with its own rate-limit reset), a11y scan waits for the new section. Full api suite (107 files / 607 tests), auth/admin-settings/a11y packs green against a fresh local stack. Closes #332
56 lines
1.8 KiB
TypeScript
56 lines
1.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
GENERAL_FORM_FIELDS,
|
|
GeneralSettingsForm,
|
|
toFormValues,
|
|
toSettingsPatch,
|
|
} from './admin-settings-form';
|
|
|
|
describe('admin general settings form model (issue #322)', () => {
|
|
// The regression this file exists for: a dotted field name makes
|
|
// react-hook-form nest the typed value and the strict PATCH schema
|
|
// reject the body — the form then looks fine but never saves.
|
|
it('uses no dots in any form field name', () => {
|
|
for (const field of Object.keys(GENERAL_FORM_FIELDS)) {
|
|
expect(field).not.toContain('.');
|
|
}
|
|
});
|
|
|
|
it('round-trips settings through form values back to a flat patch', () => {
|
|
const settings = {
|
|
'instance.name': 'My Wiki',
|
|
'instance.defaultLocale': 'de',
|
|
'auth.registrationMode': 'closed',
|
|
'invitations.maxOpenPerUser': 5,
|
|
'classification.newPageDefault': 'unclassified',
|
|
'classification.uploadPolicy': 'warn',
|
|
'quota.editorsPerPond': 5,
|
|
'quota.readersPerPond': 50,
|
|
'quota.additionalPonds': 0,
|
|
'quota.storageBytes': 1024,
|
|
'quota.maxFileBytes': 25,
|
|
};
|
|
expect(toSettingsPatch(toFormValues(settings))).toEqual(settings);
|
|
});
|
|
|
|
it('patches only the settings this form edits, under their dotted keys', () => {
|
|
const input: GeneralSettingsForm = {
|
|
instanceName: 'Renamed',
|
|
defaultLocale: 'en',
|
|
registrationMode: 'open',
|
|
invitationsMaxOpenPerUser: 5,
|
|
newPageClassification: 'vs_nfd',
|
|
uploadPolicy: 'block',
|
|
quotaEditorsPerPond: 1,
|
|
quotaReadersPerPond: 2,
|
|
quotaAdditionalPonds: 3,
|
|
quotaStorageBytes: 4,
|
|
quotaMaxFileBytes: 5,
|
|
};
|
|
const patch = toSettingsPatch(input);
|
|
expect(patch['instance.name']).toBe('Renamed');
|
|
expect(Object.keys(patch).sort()).toEqual(Object.values(GENERAL_FORM_FIELDS).slice().sort());
|
|
});
|
|
});
|