Skip to content

Commit 0e4dfbe

Browse files
fix(plugin-auth)!: the global mfa_required switch cannot be saved on while OS_AUTH_TWO_FACTOR=false keeps two-factor enrollment off (#30302) (#30376)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgPZouxxkUguGpanRbpEDH8s)_ domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL ## 维护者速读 - 改了什么:部署用环境变量 `OS_AUTH_TWO_FACTOR=false` 把两步验证钉成关时,Setup → 认证里的全局「强制多因素认证」(`auth.mfa_required`)从关改成开会被拒存(400 `SETTINGS_VALIDATION`,字段级本地化提示,点名这个环境变量和修法),整次保存什么都不写。另外改正两处文档:组织级 `require_mfa` 只有两步验证开着才能开,全局开关在上述钉死情况下拒存。 - 为什么改:全局开关打开时会连同两步验证插件一起开,但环境变量优先级更高,钉成关时插件开不了。于是开关存上、界面显示「开」,却没人能绑定验证器,闸门也什么都不拦,管理员以为开了强制 MFA,实际没有。组织级字段在 #30201 已经拒存这种状态,全局开关这次补齐。 - 风险与回滚:这是一次收窄(Clause-② `no (narrowing)`):只在 `OS_AUTH_TWO_FACTOR` 设为关的部署上,以前能存的「开」现在被拒。环境变量未设或设为开时行为不变;关掉开关、原值回存永远放行;升级前已经存成「开」的值原样保留(照旧不生效)。回滚即撤本 PR。 - 先看:`packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts`(规则与四语文案);`auth-plugin.ts` 里 `bindAuthSettings` 的注册一行(紧挨 `allowed_ip_ranges` 那条写谓词);`content/docs/permissions/authentication.mdx` 的 `mfa_required` 行。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/mfa-required-enrollment-predicate.test.ts`,预期 7/7 通过,其中一条断言 `OS_AUTH_TWO_FACTOR=false` 下保存被拒且什么都没写、`features.twoFactor` 仍为 false。 - 需要你做的:无,席位落地。「未强制」的信号不新增字段:`GET /api/v1/auth/config` 的 `features.twoFactor` 已与闸门同一判定,界面半边(#30295)读它即可。 Fixes #30302 Clause-②: no (narrowing) — while `OS_AUTH_TWO_FACTOR` is set to off, a save that turns the `auth.mfa_required` setting on is refused `400 SETTINGS_VALIDATION` (`invalid_value` at `mfa_required`), a value an administrator could save before; saving it off, or re-saving a value already in force, passes. No published export, error code, settings key or `/auth/config` member is added; nothing is widened. Metadata-protocol: none Skills-docs: updated here — `content/docs/permissions/authentication.mdx` (the `mfa_required` row) and `docs/HARDENING.md` (the MFA row); `skills/**` teaches neither `mfa_required` nor `require_mfa`, so no skill or eval moves. ## Why Three should-fix items of the `CONTRACT_REVIEW_TIER` review of PR #30293 (record 6650579682, carried in 6651713006), filed as this card. 1. **The global switch saved unenforced.** `bindAuthSettings` applies `mfa_required = true` as one refusable piece, `{ mfaRequired: true, plugins: { twoFactor: true } }`, so the requirement is never in force without its enrollment path. But `resolveTwoFactorEnabled` reads `OS_AUTH_TWO_FACTOR` first, so under `OS_AUTH_TWO_FACTOR=false` the piece's `plugins.twoFactor: true` changes nothing: the switch saved and read on, nobody could enroll, and the gate (which since #30201 holds only a user who can enroll) enforced nothing. Measured: `OS_AUTH_TWO_FACTOR` is not a settings env override (the `auth` manifest has no two-factor key; the override for `mfa_required` itself would be `OS_AUTH_MFA_REQUIRED`); it is read only by `resolveTwoFactorEnabled`, i.e. on `plugins.twoFactor`. 2. **No "not enforced" signal.** A stored requirement reads as required while two-factor is off. 3. **Docs.** Both docs said `require_mfa` is settable per organization with no condition. ## What changes - **The refusal (item 1).** `mfa-required-enrollment-predicate.ts` registers a settings write predicate on `auth.mfa_required` (`SettingsService.registerWritePredicate`, the seam `allowed_ip_ranges` and `localization.currency` use), from `bindAuthSettings`, beside the `allowed_ip_ranges` predicate and before the first settings pass. The settings service asks it before any row is written, only when the save changes the key. It refuses a change from off to on (stored toggles read as `bindAuthSettings` reads them) while `resolveTwoFactorEnabled({ twoFactor: true })` answers false, which is the gate's own decision point over the plugin config the piece applies, so exactly an `OS_AUTH_TWO_FACTOR` value that reads as off. The refusal refuses the whole save (`SettingsValidationError`, `SETTINGS_VALIDATION`) with one field error at `mfa_required`, `invalid_value`, constraint `{ twoFactor: false, overriddenBy: 'OS_AUTH_TWO_FACTOR' }`, and a message in the caller's language (en, zh-CN, ja-JP, es-ES; others fall back to English) naming the env var and the remedy (set it to true or remove it, restart, then turn the switch on). The rule reads the env var alone, so a sibling key in the same save cannot let it through (no batch hole). A settings service without write predicates is said once at `warn`, only while the pin makes it matter. - **The signal (item 2): already there, documented.** `GET /api/v1/auth/config` serves `features.twoFactor` from `resolveTwoFactorEnabled(this.config.plugins)` (since #30201), the same function the gate, the plugin list and both refusals read, live per request. `features.twoFactor: false` beside a stored `require_mfa = true` (or a stored global switch) is exactly "required but not enforced", and it is spec-typed already (`AuthFeaturesConfigSchema.twoFactor`) and readable from page predicates (`features.twoFactor`, as `sys-user.page.ts` does). So no `/auth/config` member is added, `packages/spec` is untouched, and the new test pins the reading on the refused path and on a switch stored on before the check. The UI half (#30295) reads it. - **Docs (item 3).** The `mfa_required` row of `content/docs/permissions/authentication.mdx` and the MFA row of `docs/HARDENING.md` now say the per-organization `require_mfa` needs two-factor enrollment on, that the global switch is refused under `OS_AUTH_TWO_FACTOR=false`, and (mdx) that `features.twoFactor: false` reports a stored requirement as not enforced. - **Unchanged.** With `OS_AUTH_TWO_FACTOR` unset or on, the switch saves and the pass turns the requirement and enrollment on together, as before. A switch saved on before this release is not touched (the settings service asks no predicate about a value already in force) and stays unenforced, which `features.twoFactor` says. ## Tests `packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts`, 7 cases, through the real `SettingsService` and the real `AuthPlugin` / `AuthManager` boot (no data engine composed): 1. `OS_AUTH_TWO_FACTOR=false`: `setMany('auth', { mfa_required: true, mfa_grace_period_days: 3 })` is refused, code + field + constraint asserted; nothing in the batch is stored; the runtime requirement, `isTwoFactorEnabled()` and `features.twoFactor` stay off; saving off passes. 2. The real `PUT /api/settings/:namespace` route answers 400 `SETTINGS_VALIDATION` with the field error in zh-CN (`OS_AUTH_TWO_FACTOR=0`). 3. Controls (env unset, env `true`): the save is accepted and the pass turns the requirement, enrollment and `features.twoFactor` on. 4. A switch stored on before the check is not re-judged; it reads on, `features.twoFactor` reads false. 5. The predicate alone: only off-to-on is judged, stored-toggle spellings, the English fallback. 6. The registration warn fires only under the pin. Ablations (each through `scripts/ablation-replace.mjs`, anchor hit 1 → 0, restored to the HEAD blob, `git diff HEAD` empty): - A: delete the registration call in `auth-plugin.ts` → cases 1 and 2 red (`the save went through`), 2 failed / 5 passed. - B: delete `if (enrollable()) return undefined;` → both controls and the predicate case red, 3 failed / 4 passed. ## Acceptance notes - The `auth` manifest's `mfa_required` description ("Enabling this also turns on the two-factor feature so users can enroll", `packages/services/service-settings/src/manifests/auth.manifest.ts`) is outside this card's file surface. With the refusal it holds for every save that lands; under the pin the save is refused with the reason. - `OS_AUTH_MFA_REQUIRED=true` (the settings env override of the switch itself) beside `OS_AUTH_TWO_FACTOR=false` is a deployment contradiction no save door sees; the gate's once-per-process warning (#30201) names it. Not filed: no new reach. ## Verification All readings on head `7e672312` (this branch merged with `origin/main` at `d1d90b70`, which carries #30309), under `scripts/pm/os-verify-lock.sh`, shared box. <details><summary>Suites and typecheck</summary> - `pnpm --filter '@objectstack/plugin-auth...' build && pnpm --filter @objectstack/plugin-auth test && pnpm --filter @objectstack/plugin-auth typecheck`: `VERDICT command-exit 0`; Test Files 223 passed (223), Tests 3925 passed, 10 skipped (3935); `check:test-typecheck: OK`. - The new file alone: 7/7. Ablations A and B as above, each restored with `git diff HEAD` empty. - `pnpm lint`: exit 0. </details> <details><summary>Full dogfood battery (owed by the narrowing arm)</summary> `pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test` (no shard, no path): `VERDICT command-exit 0`, held the lock 27m01s; Test Files 419 passed, 3 skipped (422); Tests 3492 passed, 36 skipped (3528); no failure. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 95 commands on `7e672312`; each was run with its exit code recorded before any pipe. `--ran`: 95 derived, 94 run, 1 NOT-MEASURED, 0 UNRUN. NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: it needs a whole-workspace build; CI runs it). `check:skill-examples` first exited 3 (client packages unbuilt) and passed (exit 0, 283 examples) after building `@objectstack/client` and `@objectstack/client-react`. Every other command exited 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZsesqReyo7ausntP8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8fad7b1 commit 0e4dfbe

6 files changed

Lines changed: 435 additions & 2 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth)!: the global `auth.mfa_required` switch cannot be saved on while `OS_AUTH_TWO_FACTOR=false` keeps two-factor enrollment off (#30302)
6+
7+
Clause-②: no (narrowing) — while `OS_AUTH_TWO_FACTOR` is set to off, a save that turns the `auth.mfa_required` setting on is refused `400 SETTINGS_VALIDATION` (`invalid_value` at `mfa_required`), a value an administrator could save before; saving it off, or re-saving a value already in force, passes. No published export, error code, settings key or `/auth/config` member is added; nothing is widened.
8+
Metadata-protocol: none
9+
Skills-docs: updated here — `content/docs/permissions/authentication.mdx` (the `mfa_required` row) and `docs/HARDENING.md` (the MFA row); `skills/**` teaches neither `mfa_required` nor `require_mfa`, so no skill or eval moves.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, and no stored row is read, rewritten, converted or dropped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime accept set on one settings key, `auth.mfa_required`, judged against the deployment's `OS_AUTH_TWO_FACTOR` env var at save time; the remedy is a deployment change (set the env var on, or remove it), which no ledger entry can derive. The other categories are closed on facts: the bumped package publishes (not unpublished); no ADR-0087 id covers this key and this diff adds none (not registered, not already-registered); no published export is added, removed or retyped, so no interface or type narrows (not runtime-export-retired, runtime-interface-only or type-surface-only). -->
12+
13+
**BREAKING** accept-set narrowing on the `auth.mfa_required` settings save (Setup → Authentication, `PUT /api/settings/auth`, `SettingsService.setMany`), on a deployment that sets `OS_AUTH_TWO_FACTOR` to off.
14+
15+
- **Before.** Turning on "Require multi-factor authentication" saved and read as on. The settings pass turns the requirement and the two-factor plugin on as one piece, but the `OS_AUTH_TWO_FACTOR` env var outranks the plugin flag, so under `OS_AUTH_TWO_FACTOR=false` nobody could enroll an authenticator app, and the MFA gate, which holds only a user who can enroll, enforced nothing. An organization's own `require_mfa` already refused this state; the global switch did not.
16+
- **Now.** That save is refused before anything is written: the whole save, other keys in it included, with one field error at `mfa_required` naming `OS_AUTH_TWO_FACTOR`, in the caller's language (English, Simplified Chinese, Japanese, Spanish), and the constraint `{ twoFactor: false, overriddenBy: 'OS_AUTH_TWO_FACTOR' }`.
17+
- **Unchanged.** With `OS_AUTH_TWO_FACTOR` unset or on, the switch saves and turns enrollment on with the requirement, as before. Turning it off is never refused. A switch saved on before this release is left as stored and still not enforced; `GET /api/v1/auth/config` answers `features.twoFactor: false` there, the same decision the gate makes, which is the reading a console uses to say the requirement is not enforced.
18+
19+
**The remedy.** Set `OS_AUTH_TWO_FACTOR=true` on the deployment (or remove it) and restart, then turn on `auth.mfa_required`.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ often before the account-level lock engages.
604604

605605
| Setting | Effect |
606606
|---|---|
607-
| `mfa_required` | Require MFA org-wide (also settable per organization via `require_mfa`). |
607+
| `mfa_required` | Require MFA org-wide; saving it on also turns two-factor enrollment on, and the save is refused while `OS_AUTH_TWO_FACTOR=false` pins enrollment off. An organization can require it on its own (`require_mfa`) only while two-factor enrollment is on; a requirement stored while enrollment is off is not enforced, which `GET /api/v1/auth/config` reports as `features.twoFactor: false`. |
608608
| `mfa_grace_period_days` | Grace window for existing users to enroll. After it elapses, the session is allowed to sign in but **gated from data access** until TOTP is enrolled — the Console surfaces a remediation overlay that walks the user through enrollment. |
609609

610610
### Sessions

‎docs/HARDENING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ production deployment, enable at least:
239239
| ----------- | ------------------------------------------------------------------------------------ |
240240
| Password | `password_reject_breached: true`, `password_require_complexity: true`, `password_history_count: 3–5`, `password_expiry_days: 90` |
241241
| Anti-abuse | `lockout_threshold: 5`, `lockout_duration_minutes: 15`, `rate_limit_max` / `rate_limit_window_seconds` |
242-
| MFA | `mfa_required: true` (or per-org `require_mfa`), `mfa_grace_period_days: 7` |
242+
| MFA | `mfa_required: true` (or per-org `require_mfa`, which needs two-factor enrollment on), `mfa_grace_period_days: 7`; neither saves while `OS_AUTH_TWO_FACTOR=false` |
243243
| Sessions | `session_idle_timeout_minutes: 30–60`, `session_absolute_max_hours: 8–12`, `max_concurrent_sessions_per_user` |
244244
| Network | `allowed_ip_ranges` (IPv4 CIDR) for tenant- or office-scoped access |
245245

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969
import { registerLastAdminGuard } from './last-admin-guard.js';
7070
import { registerApiKeyRestoreGuard } from './api-key-restore-guard.js';
7171
import { registerOrganizationMfaEnrollmentGuard } from './organization-mfa-enrollment-guard.js';
72+
import { registerMfaRequiredEnrollmentPredicate } from './mfa-required-enrollment-predicate.js';
7273
import { registerPersonalViewHooks } from './personal-view-hooks.js';
7374
import { registerUserPreferenceHooks } from './user-preference-hooks.js';
7475
import { personalViewCallerResolver, registerPersonalViewRoutes, PERSONAL_VIEW_REMOVE_ROUTE, type PersonalViewEngine } from './personal-view-routes.js';
@@ -2271,6 +2272,9 @@ export class AuthPlugin implements Plugin {
22712272
// gated users would have no way to comply. [#20412] So the enforcement
22722273
// and the plugin are ONE piece: a refused plugins block leaves MFA at
22732274
// its standing value too, never enforced without its enrollment path.
2275+
// The env var OS_AUTH_TWO_FACTOR outranks this piece's plugin flag, so
2276+
// under a pin to off the save itself is refused, before this pass
2277+
// (mfa-required-enrollment-predicate.ts).
22742278
if (isExplicit('mfa_required')) {
22752279
const on = asBoolean(values.mfa_required, false);
22762280
if (on) {
@@ -2591,6 +2595,11 @@ export class AuthPlugin implements Plugin {
25912595
}
25922596
}
25932597

2598+
// The save door refuses turning `mfa_required` on while OS_AUTH_TWO_FACTOR
2599+
// pins enrollment off; the pass below would store it and enforce nothing.
2600+
// See mfa-required-enrollment-predicate.ts.
2601+
registerMfaRequiredEnrollmentPredicate(settings, ctx.logger);
2602+
25942603
await applySettings();
25952604
if (typeof settings.subscribe === 'function') {
25962605
settings.subscribe('auth', () => {
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The global `auth.mfa_required` switch cannot be saved on while
5+
* `OS_AUTH_TWO_FACTOR` pins enrollment off (`mfa-required-enrollment-predicate.ts`).
6+
*
7+
* Through the REAL settings service (`@objectstack/service-settings`) and the
8+
* real `AuthPlugin` / `AuthManager`: the plugin registers the predicate when it
9+
* binds the `auth` namespace, and a save goes through `setMany`, or through the
10+
* real `PUT /api/settings/:ns` route for the envelope. No data engine is
11+
* composed; the settings service keeps its in-memory store.
12+
*
13+
* Pinned:
14+
* 1. under `OS_AUTH_TWO_FACTOR=false`, turning the switch on is refused
15+
* before anything is written: the stored value, the batch it rode in, the
16+
* runtime requirement and `features.twoFactor` all stay as they were;
17+
* 2. the HTTP door answers 400 `SETTINGS_VALIDATION` at the key, in the
18+
* caller's language;
19+
* 3. controls: with the env var unset or on, the same save is accepted and
20+
* the pass turns the requirement and enrollment on; turning it off is
21+
* never refused;
22+
* 4. a switch stored on before the check is not re-judged: saving it again
23+
* passes, and the gate's own reading stays off.
24+
*/
25+
26+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
27+
import type { PluginContext } from '@objectstack/core';
28+
import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts';
29+
import {
30+
SettingsService,
31+
SettingsValidationError,
32+
builtinSettingsManifests,
33+
registerSettingsRoutes,
34+
} from '@objectstack/service-settings';
35+
import { AuthPlugin } from './auth-plugin.js';
36+
import type { AuthManager } from './auth-manager.js';
37+
import {
38+
MFA_REQUIRED_ENROLLMENT_COPY,
39+
mfaRequiredEnrollmentPredicate,
40+
registerMfaRequiredEnrollmentPredicate,
41+
} from './mfa-required-enrollment-predicate.js';
42+
43+
const SECRET = 'test-secret-at-least-32-chars-long';
44+
const ENV = 'OS_AUTH_TWO_FACTOR';
45+
46+
/** The error `setMany` refused with, or a failure naming what happened instead. */
47+
async function refusalOf(write: Promise<unknown>): Promise<SettingsValidationError> {
48+
const outcome = await write.then(
49+
() => undefined,
50+
(err: unknown) => err,
51+
);
52+
expect(outcome, 'the save went through').toBeInstanceOf(SettingsValidationError);
53+
return outcome as SettingsValidationError;
54+
}
55+
56+
describe('the global mfa_required switch needs two-factor enrollment', () => {
57+
let saved: string | undefined;
58+
59+
beforeEach(() => {
60+
saved = process.env[ENV];
61+
delete process.env[ENV];
62+
});
63+
64+
afterEach(() => {
65+
if (saved === undefined) delete process.env[ENV];
66+
else process.env[ENV] = saved;
67+
});
68+
69+
const makeSettings = () => {
70+
const settings = new SettingsService({ env: {} });
71+
for (const manifest of builtinSettingsManifests) {
72+
if (manifest.namespace === 'auth') settings.registerManifest(manifest);
73+
}
74+
return settings;
75+
};
76+
77+
/** Boot as a host does: init → start → kernel:ready (the auth namespace binds there). */
78+
const boot = async (settings: SettingsService = makeSettings()) => {
79+
const hooks = new Map<string, Array<() => Promise<void>>>();
80+
const warn = vi.fn();
81+
const ctx = {
82+
registerService: vi.fn(),
83+
getService: vi.fn((name: string) => {
84+
if (name === 'manifest') return { register: vi.fn() };
85+
if (name === 'settings') return settings;
86+
return undefined;
87+
}),
88+
getServices: vi.fn(() => new Map()),
89+
hook: vi.fn((name: string, handler: () => Promise<void>) => {
90+
if (!hooks.has(name)) hooks.set(name, []);
91+
hooks.get(name)!.push(handler);
92+
}),
93+
trigger: vi.fn(),
94+
logger: { info: vi.fn(), error: vi.fn(), warn, debug: vi.fn() },
95+
getKernel: vi.fn(),
96+
} as unknown as PluginContext;
97+
const plugin = new AuthPlugin({ secret: SECRET, baseUrl: 'http://localhost:3000' });
98+
await plugin.init(ctx);
99+
const manager = (ctx.registerService as any).mock.calls.find(([name]: [string]) => name === 'auth')?.[1] as AuthManager;
100+
await plugin.start(ctx);
101+
for (const handler of hooks.get('kernel:ready') ?? []) await handler();
102+
return { settings, manager, warn };
103+
};
104+
105+
const required = (manager: AuthManager) => (manager as any).config.mfaRequired === true;
106+
107+
// ── 1. The refusal ──────────────────────────────────────────────────────────
108+
109+
it('OS_AUTH_TWO_FACTOR=false: turning it on is refused; nothing in the batch is written', async () => {
110+
process.env[ENV] = 'false';
111+
const { settings, manager } = await boot();
112+
113+
const refusal = await refusalOf(settings.setMany('auth', { mfa_required: true, mfa_grace_period_days: 3 }));
114+
expect(refusal.code).toBe('SETTINGS_VALIDATION');
115+
expect(refusal.fields).toEqual([
116+
expect.objectContaining({
117+
field: 'mfa_required',
118+
code: 'invalid_value',
119+
constraint: { twoFactor: false, overriddenBy: 'OS_AUTH_TWO_FACTOR' },
120+
}),
121+
]);
122+
123+
expect(await settings.get('auth', 'mfa_required')).toMatchObject({ value: false, source: 'default' });
124+
expect(await settings.get('auth', 'mfa_grace_period_days')).toMatchObject({ source: 'default' });
125+
expect(required(manager)).toBe(false);
126+
expect(manager.isTwoFactorEnabled()).toBe(false);
127+
expect(manager.getPublicConfig().features.twoFactor).toBe(false);
128+
// Off, or the value in force, is never refused.
129+
await expect(settings.setMany('auth', { mfa_required: false })).resolves.toBeDefined();
130+
});
131+
132+
it('the settings PUT door answers 400 SETTINGS_VALIDATION at the key, in the caller language', async () => {
133+
process.env[ENV] = '0';
134+
const { settings } = await boot();
135+
const routes = new Map<string, RouteHandler>();
136+
const add = (method: string) => (path: string, handler: RouteHandler) => {
137+
routes.set(`${method} ${path}`, handler);
138+
return http;
139+
};
140+
const http = {
141+
get: add('GET'),
142+
post: add('POST'),
143+
put: add('PUT'),
144+
delete: add('DELETE'),
145+
patch: add('PATCH'),
146+
use: () => http,
147+
listen: async () => {},
148+
close: async () => {},
149+
getInstance: () => null,
150+
} as unknown as IHttpServer;
151+
registerSettingsRoutes(http, settings, {
152+
contextFromRequest: () => ({ enforced: true, permissions: ['manage_platform_settings'], userId: 'usr_admin' }),
153+
});
154+
155+
const state: { status: number; body?: any } = { status: 200 };
156+
const res = {
157+
json: vi.fn((data: unknown) => {
158+
state.body = data;
159+
}),
160+
send: vi.fn(),
161+
status: vi.fn((code: number) => {
162+
state.status = code;
163+
return res;
164+
}),
165+
header: vi.fn(() => res),
166+
} as unknown as IHttpResponse;
167+
const req = {
168+
params: { namespace: 'auth' },
169+
query: {},
170+
body: { mfa_required: true },
171+
headers: { 'accept-language': 'zh-CN,zh;q=0.9' },
172+
method: 'PUT',
173+
path: '/api/settings/auth',
174+
} as unknown as IHttpRequest;
175+
await routes.get('PUT /api/settings/:namespace')!(req, res);
176+
177+
expect(state.status).toBe(400);
178+
expect(state.body.error.code).toBe('SETTINGS_VALIDATION');
179+
const [field] = state.body.error.details.fields;
180+
expect(field).toMatchObject({ field: 'mfa_required', code: 'invalid_value' });
181+
expect(field.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY['zh-CN']);
182+
});
183+
184+
// ── 3. Controls ─────────────────────────────────────────────────────────────
185+
186+
it.each([
187+
['unset', undefined],
188+
['on', 'true'],
189+
])('control: OS_AUTH_TWO_FACTOR %s — the save is accepted and the pass turns enrollment on with it', async (_label, value) => {
190+
if (value !== undefined) process.env[ENV] = value;
191+
const { settings, manager } = await boot();
192+
193+
await settings.setMany('auth', { mfa_required: true });
194+
expect(await settings.get('auth', 'mfa_required')).toMatchObject({ value: true });
195+
await vi.waitFor(() => expect(required(manager)).toBe(true));
196+
expect(manager.isTwoFactorEnabled()).toBe(true);
197+
expect(manager.getPublicConfig().features.twoFactor).toBe(true);
198+
});
199+
200+
// ── 4. A switch stored on before the check ──────────────────────────────────
201+
202+
it('a switch stored on before the check is not re-judged, and the gate still reads enrollment off', async () => {
203+
const settings = makeSettings();
204+
await settings.setMany('auth', { mfa_required: true });
205+
process.env[ENV] = 'false';
206+
const { manager } = await boot(settings);
207+
208+
// The value in force: the settings service asks no predicate about it.
209+
await expect(settings.setMany('auth', { mfa_required: true })).resolves.toBeDefined();
210+
// Read as on, enforced by nobody: `features.twoFactor` is what says so.
211+
expect(required(manager)).toBe(true);
212+
expect(manager.isTwoFactorEnabled()).toBe(false);
213+
expect(manager.getPublicConfig().features.twoFactor).toBe(false);
214+
});
215+
216+
// ── The predicate and its registration, alone ───────────────────────────────
217+
218+
it('judges a change to on only, and only while enrollment stays off', () => {
219+
const off = mfaRequiredEnrollmentPredicate(() => false);
220+
expect(off({ next: true, current: false })?.constraint).toEqual({ twoFactor: false, overriddenBy: 'OS_AUTH_TWO_FACTOR' });
221+
// Stored toggles read as `bindAuthSettings` reads them.
222+
expect(off({ next: 'true', current: null })).toBeDefined();
223+
expect(off({ next: 1, current: undefined })).toBeDefined();
224+
expect(off({ next: false, current: true })).toBeUndefined();
225+
expect(off({ next: null, current: true })).toBeUndefined();
226+
expect(off({ next: true, current: 'true' })).toBeUndefined();
227+
expect(mfaRequiredEnrollmentPredicate(() => true)({ next: true, current: false })).toBeUndefined();
228+
// Unknown locales fall back to English.
229+
expect(off({ next: true, current: false, locale: 'fr-FR' })?.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY.en);
230+
});
231+
232+
it('a settings service without write predicates is said at warn, only while the pin makes it matter', () => {
233+
const warn = vi.fn();
234+
registerMfaRequiredEnrollmentPredicate({}, { warn });
235+
expect(warn).not.toHaveBeenCalled();
236+
process.env[ENV] = 'false';
237+
registerMfaRequiredEnrollmentPredicate({}, { warn });
238+
expect(warn).toHaveBeenCalledTimes(1);
239+
expect(String(warn.mock.calls[0][0])).toMatch(/OS_AUTH_TWO_FACTOR/);
240+
});
241+
});

0 commit comments

Comments
 (0)