Skip to content

Commit 91e7286

Browse files
feat(service-settings): warn when a settings value misses the format its type declares (18.x half, 「甲」) (#25001)
Part of #24882. This PR is the 18.x warning half only; refusing these values is the next major's half, which the `domain:services` seat files when this lands. Clause-②: no (warning only: every save and every `OS_*` override accepted before is still accepted, with the same value stored or served, the same status and the same errors) ## What changes ADR-0007's specifier table declares a value format for four types: `email` / `url` / `phone` take "format validation", and `color` takes a "hex string". Nothing judged it. `PUT /api/settings/branding` stored a Logo URL of `not a url` and answered 200, and an `OS_*` override was served unchecked. `packages/services/service-settings/src/settings-service.ts` now judges that format at both doors through one shared helper, `firstFormatMiss(type, value)`. The helper returns a verdict that carries the ADR-0114 code the refusal will use (`invalid_email` / `invalid_url` / `invalid_phone` / `invalid_format`). Both call sites report a miss and keep the value: - **Save door (`validatePatch`).** The format branch runs after every refusal family, so the errors a refused patch carries are exactly what they were. A miss is collected, never pushed to `errors`. When the patch as a whole is accepted, one `warn` line is logged per key, naming the namespace, key, type, the expected format and the value's length. - **Env door (`effectiveEnvOverride`).** The format is judged last, only on an override that every refusal family has already let through. A miss draws one `warn` line, deduplicated per (variable, value) like the existing rejection report. The override stays in force: it is served with `source: 'env'` and it still locks the key. `auditEnvOverrides` also walks format-typed keys, so the line arrives at boot rather than on a first read. - **The value is never printed** in either line, for any key. That covers the secret-bearing case, and it keeps an environment variable's value out of the log. The grammars are the platform's existing ones for the same words, not new dialects: | type | grammar | why this one | |---|---|---| | `email` | local@domain with a dotted domain, no spaces | the address check of `plugin-email`, the live reader of `mail.from_email`. A value without a warning is one the transport can send from. `no-reply@localhost` warns. | | `url` | `scheme://…`, a root-relative `/path`, `data:`, or `blob:` | the record validator's `url` field grammar. Not http(s)-only: the reader of `branding.logo_url` (`RuntimeConfigPlugin.logoUrl`) declares "absolute or relative URL", and storage hands back `/api/v1/storage/files/…` for an upload. | | `phone` | digits, spaces and `+ ( ) - .`, at least 5 characters | the record validator's `phone` field grammar. No shipped manifest authors a `phone` specifier (`company.phone` is `text`). | | `color` | `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` | CSS hex notation. ADR-0007 says "hex string", the picker emits `#rrggbb`, and the reader serves it as a browser theme colour. | Empty values, blank values and non-string values are not judged, matching how `firstPatternMiss` treats them. Surrounding whitespace is ignored because both live readers trim before use. ## 「甲」 reading The standing rule (#6015, comment 6292005854), verbatim: "No conversion can carry it: 18.x ships only the loud half (a warning, an explain line, a reported event). The refusal is filed for 19.0." The seat's derivation for this card is comment 6302777692: - Refusing a malformed `email` / `url` / `phone` / `color` save narrows what the settings door accepts today. - Stored values live in `sys_setting` (runtime state). An `OS_*` override is a deployment environment value. No ADR-0087 conversion can rewrite either. - So 18.x warns. This PR implements exactly that: - No `FieldError`, no `400` and no new `409` anywhere. - The response shape is unchanged. - Every stored and served value is byte-identical to before. The pins below assert all of this through the real route. The refusal half reads `miss.code` from the verdict, so turning the warning into a `FieldError` changes the two call sites and nothing else. **Where the warning reaches.** Measured at `7adfaec2a`: - The `PUT` answer is `{ values }` only. - `setMany` returns a record of `ResolvedSettingValue` keyed by setting, which has no advisory member. - The `settings:changed` event (`SettingsChangeEventSchema`) and the two audit sinks carry no advisory member either. So the server log, through the service's existing `logger.warn` (wired from `ctx.logger`; `console.warn` without one), is the only surface reachable without a new response member. Whether a log-only warning is loud enough for an admin saving in Setup is put to the seat as a decision in the report. The console half (constraint validation on Save) is objectui's, beside objectstack-ai/objectui#13239. ## Tests New file `packages/services/service-settings/src/settings-format-warning.test.ts`, with 31 cases. The save door is driven through the real route (`registerSettingsRoutes` over a real `SettingsService`, as `settings-routes.test.ts` does). Status starts at 0, so `200` is asserted rather than defaulted. The env door is driven through `get`, `getNamespace` and the `PUT` lock. - Per type, on every shipped key with the type: `branding.logo_url`, `company.website`, `branding.accent_color`, `company.primary_contact_email` and `mail.from_email`, plus a synthetic `phone` key. - A malformed save answers 200, is stored as written, and draws exactly one warning naming namespace, key and type, without the value. - A well-formed save draws none. - Per type at the env door: - A malformed override is in force (`source: 'env'`, `locked: true`, a save still answers 409). It draws exactly one warning, at boot, which re-reads do not repeat. - A well-formed override draws none. - A secret-bearing `email` key at both doors: the value and its fragments never appear in the warning. - Precedence: a value refused for `maxLength` carries only the `max_length` FieldError. A batch refused for one key stores nothing and warns about nothing. - The other shapes each grammar admits (relative, `data:` and `http:` logos, 3/4/6/8-digit hex, a padded email) draw no warning. Empty and blank values draw no warning. - The synthetic fixture parses under `SettingsManifestSchema`. Readings on the final head `b3b236b77`, each through `scripts/pm/os-verify-lock.sh` (VERDICT command-exit 0): - `pnpm --filter @objectstack/service-settings run typecheck`: exit 0. The program includes the new test (`tsc --listFiles`, 57 test files). - `pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2`: 57 files, 960 tests passed. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` over `auth-settings-ordering.pin`, `auth-settings-seeded-boot.pin` and `auth-settings-refusal-isolation`: 3 files, 13 tests passed. These import `service-settings` from source. ## Ablations Each leg ran on `b3b236b77` with the implementation committed. The mutation went through `node scripts/ablation-replace.mjs` in WRAP mode: the anchor hit 1 time and fell to 0, and the blob changed on disk. A trap-armed wrapper then ran the format pins and restored the file. The restore is proven with blob == HEAD (`f3caa344f9a9`) and an empty `git diff HEAD`. The tests import the service from source, so no `dist/` is involved. | leg | mutation | result | |---|---|---| | A1 | the judge accepts everything (`return null`) | 14 red: every malformed-value pin at both doors, the secret-bearing ones included (`expected [] to have a length of 1 but got +0`) | | A2s | the save door refuses a miss (`errors.push` + `continue`) | 8 red: the 7 save-door malformed pins (`expected 400 to be 200`), and the precedence pin (`[ 'support_phone', 'docs_url' ]` vs `[ 'docs_url' ]`) | | A2e | the env door rejects a miss (`return null`) | 7 red: every env-door malformed pin (`expected null to be …`, `expected [] to have a length of 1`) | | A3 | the judge flags every value | 13 red: every well-formed pin at both doors, and the other-shapes pin | | A4 | the warning echoes the value | 14 red: every malformed pin at both doors, the two secret-bearing pins included (`expected … not to contain …`) | | A5 | the value is judged untrimmed | 1 red: the other-shapes pin (padded email) | ## Gates Gate commands were derived from this worktree with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) on `b3b236b77`: 67 commands. Each ran locally, with its exit code captured before any pipe. The readings are in the os-dev report on the card. CI owns the families outside that list. ## Acceptance notes - Grammar mirrors. The email grammar is now spelled in four places: the record validator, the rule validator, `plugin-email` and this file. The url and phone grammars are spelled in two places each, and the record validator's and rule validator's phone grammars already differ. No shared predicate exists in `packages/spec`, and nothing pins the copies equal. Owner: none. - Non-string values on string-persisting specifiers. ADR-0007 says "Persists: string". A number written to `logo_url` is stored as given and dropped by the branding reader. That is a type constraint, not a format, so it is left alone here exactly as `pattern` leaves it. It was not measured at a public door. Owner: none. - `company.phone` is authored as `type: 'text'` with an "E.164 recommended" description, so no phone format applies to the one phone-like shipped key. The manifests are outside this card. Owner: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0a3ce08 commit 91e7286

3 files changed

Lines changed: 571 additions & 3 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
feat(service-settings): a settings value that misses the format its `email`, `url`, `phone` or `color` type declares is now reported in the server log; the save is still accepted (#24882)
6+
7+
Clause-②: no (warning only: every save and every `OS_*` override accepted before is still accepted, with the same value stored or served, the same status and the same errors)
8+
9+
ADR-0007 declares a format for four specifier types: `email`, `url` and `phone` take "format validation", and `color` takes a hex string. Until now nothing judged that format. `PUT /api/settings/branding` stored a Logo URL of `not a url` and answered 200, and an `OS_*` override was served unchecked.
10+
11+
Both doors now judge the format through one shared check, and report a miss instead of refusing it:
12+
13+
- **Save.** When a save is accepted and a key it touches holds a value that misses its type's format, the service logs one `warn` line for that key. The line names the setting, the type, the expected format and the value's length.
14+
- **Environment override.** An in-force `OS_*` override whose value misses the format draws one `warn` line, at boot or on the first read. The line names the variable, the setting, the type and the expected format. The override stays in force, is served with source `env`, and still locks the key.
15+
- **The value is never printed** in either line, for any key.
16+
17+
The formats are the platform's existing grammars for the same words:
18+
19+
- `email`: an address with a dotted domain, the grammar the mail transport sends with.
20+
- `url`: `scheme://…`, a root-relative `/path`, a `data:` URI or a `blob:` URL. An http(s)-only grammar would flag a relative logo path that the branding reader serves.
21+
- `phone`: digits, spaces and `+ ( ) - .`, at least five characters.
22+
- `color`: `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`.
23+
24+
Empty values and non-string values are not judged. Surrounding whitespace is ignored, because the readers trim it before use.
25+
26+
**Unchanged.** Status codes, response bodies, stored values, the `FieldError`s a refused save carries, and which overrides are in force. Under the standing rule 「甲」, refusing these values waits for the next major version.
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24882 — the format each specifier TYPE declares (ADR-0007: `email` / `url` /
5+
* `phone` "format validation", `color` "hex string") is judged at both doors
6+
* and, under the standing rule 「甲」, REPORTED rather than refused.
7+
*
8+
* Refusing these values narrows what the settings door has always accepted,
9+
* and no ADR-0087 conversion can carry that: stored values are runtime state
10+
* and an `OS_*` override is a deployment's environment. So this major version
11+
* ships only the loud half, and these pins hold both of its halves:
12+
*
13+
* - ACCEPTED, unchanged: a malformed save still answers `200` and the value is
14+
* stored and served as written; a malformed override is still in force,
15+
* still served with `source: 'env'` and still locks the key.
16+
* - REPORTED: exactly one `warn` line, naming namespace, key and type — and
17+
* never the value, for any key, so a secret-bearing key's value cannot reach
18+
* a log line through it.
19+
*
20+
* The save door is driven through the real route (`registerSettingsRoutes`
21+
* over a real `SettingsService`, as `settings-routes.test.ts` does); the env
22+
* door through the service's own read and write paths, which is where an
23+
* override takes effect. Shipped manifests are used wherever a shipped key has
24+
* the type; `phone` and a secret-bearing key are synthetic because no shipped
25+
* manifest authors either, and both fixtures are parsed against
26+
* `SettingsManifestSchema` below so they pin a shape an author can write.
27+
*/
28+
29+
import { describe, expect, it, vi } from 'vitest';
30+
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
31+
import { SettingsManifestSchema, type SettingsManifest } from '@objectstack/spec/system';
32+
import { SettingsService } from './settings-service.js';
33+
import { registerSettingsRoutes } from './settings-routes.js';
34+
import { brandingSettingsManifest } from './manifests/branding.manifest.js';
35+
import { companySettingsManifest } from './manifests/company.manifest.js';
36+
import { mailSettingsManifest } from './manifests/mail.manifest.js';
37+
import { LocalCryptoProvider } from './local-crypto-provider.js';
38+
39+
class MockHttp implements IHttpServer {
40+
routes = new Map<string, RouteHandler>();
41+
private add(method: string, path: string, handler: RouteHandler) {
42+
this.routes.set(`${method} ${path}`, handler);
43+
}
44+
get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; }
45+
post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; }
46+
put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; }
47+
delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; }
48+
patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; }
49+
use() { return this as any; }
50+
listen() { return Promise.resolve(); }
51+
close() { return Promise.resolve(); }
52+
getInstance() { return null; }
53+
}
54+
55+
/** A diagnostics sink that records both levels, so an `error` line would be seen too. */
56+
function spyLogger() {
57+
const warns: string[] = [];
58+
const errors: string[] = [];
59+
return {
60+
warns,
61+
errors,
62+
logger: {
63+
warn: (m: string) => void warns.push(m),
64+
error: (m: string) => void errors.push(m),
65+
},
66+
};
67+
}
68+
69+
/** Synthetic: a `phone` specifier (no shipped manifest authors one) and a secret-bearing `email`. */
70+
const formatLabManifest = {
71+
namespace: 'format_lab',
72+
version: 1,
73+
label: 'Format Lab',
74+
scope: 'global',
75+
readPermission: 'setup.access',
76+
writePermission: 'setup.write',
77+
specifiers: [
78+
{ type: 'phone', key: 'support_phone', label: 'Support phone', required: false },
79+
{ type: 'email', key: 'billing_email', label: 'Billing email', required: false, encrypted: true },
80+
{ type: 'url', key: 'docs_url', label: 'Docs URL', required: false, maxLength: 12 },
81+
],
82+
} as unknown as SettingsManifest;
83+
84+
/** The value a secret-bearing pin must never see in a log line. */
85+
const SECRET_MALFORMED = 'billing-desk-at-corp-internal';
86+
87+
const ADMIN = () => ({
88+
enforced: true,
89+
permissions: ['setup.access', 'setup.write', 'manage_platform_settings'],
90+
userId: 'usr_admin',
91+
organizationId: 'org_a',
92+
});
93+
94+
function makeStack(env: Record<string, string> = {}) {
95+
const spy = spyLogger();
96+
const secretRows = new Map<string, any>();
97+
const svc = new SettingsService({
98+
env,
99+
logger: spy.logger,
100+
// A confidentiality-providing crypto pair, so a write to the secret-bearing
101+
// key is accepted by the fail-closed encryption gate and actually stored.
102+
cryptoProvider: new LocalCryptoProvider(),
103+
secretStore: {
104+
async insert(row) { secretRows.set(row.id, row); return { id: row.id }; },
105+
async get(id) { return secretRows.get(id) ?? null; },
106+
async update(id, patch) { secretRows.set(id, { ...secretRows.get(id), ...patch }); },
107+
},
108+
});
109+
svc.registerManifest(brandingSettingsManifest);
110+
svc.registerManifest(companySettingsManifest);
111+
svc.registerManifest(mailSettingsManifest);
112+
svc.registerManifest(formatLabManifest);
113+
const http = new MockHttp();
114+
registerSettingsRoutes(http, svc, { contextFromRequest: ADMIN });
115+
return { svc, http, ...spy };
116+
}
117+
118+
/** `PUT /api/settings/:namespace` through the real route; status starts at 0 so `200` is asserted, not defaulted. */
119+
async function put(http: MockHttp, namespace: string, body: Record<string, unknown>) {
120+
const req: IHttpRequest = { params: { namespace }, query: {}, body, headers: {}, method: 'PUT', path: '/' };
121+
const state: { status: number; body?: any } = { status: 0 };
122+
const res: IHttpResponse = {
123+
json: vi.fn((data) => { state.body = data; }) as any,
124+
send: vi.fn() as any,
125+
status: vi.fn((code: number) => { state.status = code; return res; }) as any,
126+
header: vi.fn(() => res) as any,
127+
};
128+
await http.routes.get('PUT /api/settings/:namespace')!(req, res);
129+
return state;
130+
}
131+
132+
/** One malformed and one well-formed value per type, each on a key whose type declares it. */
133+
const PER_TYPE = [
134+
{ type: 'url', ns: 'branding', key: 'logo_url', bad: 'not a url', good: 'https://cdn.example.com/logo.svg' },
135+
{ type: 'url', ns: 'company', key: 'website', bad: 'example dot com', good: 'https://example.com' },
136+
{ type: 'color', ns: 'branding', key: 'accent_color', bad: 'e11d48', good: '#e11d48' },
137+
{ type: 'email', ns: 'company', key: 'primary_contact_email', bad: 'ops at example.com', good: 'ops@example.com' },
138+
{ type: 'email', ns: 'mail', key: 'from_email', bad: 'no-reply@localhost', good: 'no-reply@example.com' },
139+
{ type: 'phone', ns: 'format_lab', key: 'support_phone', bad: 'call the desk', good: '+1 415 555 0100' },
140+
] as const;
141+
142+
describe('synthetic fixture is a spec-valid authoring surface', () => {
143+
it('format_lab parses under SettingsManifestSchema', () => {
144+
// A fixture spelling a key the schema rejects would pin behaviour no author
145+
// can reach.
146+
expect(() => SettingsManifestSchema.parse(formatLabManifest)).not.toThrow();
147+
});
148+
});
149+
150+
describe('save door — a format miss is accepted and stored, and warned about once (#24882, 「甲」)', () => {
151+
for (const c of PER_TYPE) {
152+
it(`${c.ns}.${c.key} (${c.type}): a malformed value answers 200, is stored as written, and draws exactly one warning`, async () => {
153+
const { svc, http, warns, errors } = makeStack();
154+
const r = await put(http, c.ns, { [c.key]: c.bad });
155+
156+
// ACCEPTED: no refusal anywhere — the status, the envelope and the store.
157+
expect(r.status).toBe(200);
158+
expect(r.body.success).toBe(true);
159+
expect(r.body.error).toBeUndefined();
160+
expect(r.body.data.values[c.key].value).toBe(c.bad);
161+
const stored = await svc.get(c.ns, c.key, ADMIN());
162+
expect(stored.value).toBe(c.bad);
163+
expect(stored.source).not.toBe('default');
164+
165+
// REPORTED: exactly one line, at warn, naming namespace, key and type.
166+
expect(warns).toHaveLength(1);
167+
expect(warns[0]).toContain(`'${c.ns}.${c.key}'`);
168+
expect(warns[0]).toContain(`(type '${c.type}')`);
169+
expect(warns[0]).toContain('The save is accepted');
170+
// Never the value itself.
171+
expect(warns[0]).not.toContain(c.bad);
172+
expect(errors).toHaveLength(0);
173+
});
174+
175+
it(`${c.ns}.${c.key} (${c.type}): a well-formed value draws no warning`, async () => {
176+
const { svc, http, warns, errors } = makeStack();
177+
const r = await put(http, c.ns, { [c.key]: c.good });
178+
expect(r.status).toBe(200);
179+
expect((await svc.get(c.ns, c.key, ADMIN())).value).toBe(c.good);
180+
expect(warns).toHaveLength(0);
181+
expect(errors).toHaveLength(0);
182+
});
183+
}
184+
185+
it('the other shapes each grammar admits draw no warning either', async () => {
186+
// The readers' own contracts: `logoUrl` is "absolute or relative", storage
187+
// hands back a root-relative path for an upload, and CSS hex colours come
188+
// in four lengths.
189+
const { http, warns } = makeStack();
190+
for (const logo of ['/api/v1/storage/files/abc123', 'data:image/svg+xml;base64,PHN2Zy8+', 'http://intranet.local/logo.png']) {
191+
expect((await put(http, 'branding', { logo_url: logo })).status).toBe(200);
192+
}
193+
for (const colour of ['#abc', '#abcd', '#AABBCC', '#aabbccdd']) {
194+
expect((await put(http, 'branding', { accent_color: colour })).status).toBe(200);
195+
}
196+
// Surrounding whitespace is trimmed by both live readers before use, so it
197+
// is not a format miss; inner whitespace still is (the per-type cases above).
198+
expect((await put(http, 'company', { primary_contact_email: ' ops@example.com ' })).status).toBe(200);
199+
expect(warns).toEqual([]);
200+
});
201+
202+
it('a secret-bearing key: the malformed save is accepted, and its warning never carries the value', async () => {
203+
const { svc, http, warns } = makeStack();
204+
const r = await put(http, 'format_lab', { billing_email: SECRET_MALFORMED });
205+
expect(r.status).toBe(200);
206+
// Stored: the in-process read decrypts the real plaintext back.
207+
expect((await svc.get('format_lab', 'billing_email', ADMIN())).value).toBe(SECRET_MALFORMED);
208+
209+
expect(warns).toHaveLength(1);
210+
expect(warns[0]).toContain("'format_lab.billing_email'");
211+
expect(warns[0]).toContain("(type 'email')");
212+
expect(warns[0]).not.toContain(SECRET_MALFORMED);
213+
// Not a fragment of it either.
214+
expect(warns[0]).not.toContain('billing-desk');
215+
expect(warns[0]).not.toContain('corp-internal');
216+
});
217+
218+
it('a refused patch carries exactly the errors it carried before, and draws no format warning', async () => {
219+
const { svc, http, warns } = makeStack();
220+
// `docs_url` declares `maxLength: 12`: the length refusal keeps precedence
221+
// over the format, and the one FieldError for the key is the length one.
222+
const r = await put(http, 'format_lab', { docs_url: 'not a url at all' });
223+
expect(r.status).toBe(400);
224+
expect(r.body.error.code).toBe('SETTINGS_VALIDATION');
225+
expect(r.body.error.details.fields).toHaveLength(1);
226+
expect(r.body.error.details.fields[0].field).toBe('docs_url');
227+
expect(r.body.error.details.fields[0].code).toBe('max_length');
228+
// A batch refused for one key is refused whole: the malformed neighbour is
229+
// not stored, so it is not reported either.
230+
const r2 = await put(http, 'format_lab', { docs_url: 'not a url at all', support_phone: 'call the desk' });
231+
expect(r2.status).toBe(400);
232+
expect(r2.body.error.details.fields.map((f: { field: string }) => f.field)).toEqual(['docs_url']);
233+
expect((await svc.get('format_lab', 'support_phone', ADMIN())).source).toBe('default');
234+
expect(warns).toEqual([]);
235+
});
236+
237+
it('an empty or blank value is not a format miss', async () => {
238+
const { http, warns } = makeStack();
239+
expect((await put(http, 'branding', { logo_url: '' })).status).toBe(200);
240+
expect((await put(http, 'branding', { logo_url: ' ' })).status).toBe(200);
241+
expect(warns).toEqual([]);
242+
});
243+
});
244+
245+
describe('env door — a format-missing override stays in force, and is warned about once (#24882, 「甲」)', () => {
246+
for (const c of PER_TYPE) {
247+
const envName = `OS_${c.ns}_${c.key}`.toUpperCase();
248+
249+
it(`${envName} (${c.type}): a malformed override is in force, served and locking, with exactly one warning`, async () => {
250+
const { svc, http, warns, errors } = makeStack({ [envName]: c.bad });
251+
// Reported at boot — `registerManifest` audits the overrides — not on a
252+
// first read that may never come.
253+
expect(warns).toHaveLength(1);
254+
255+
const r = await svc.get(c.ns, c.key, ADMIN());
256+
expect(r.value).toBe(c.bad);
257+
expect(r.source).toBe('env');
258+
expect(r.locked).toBe(true);
259+
// Still locking: a save of the key is refused 409 exactly as before.
260+
const w = await put(http, c.ns, { [c.key]: c.good });
261+
expect(w.status).toBe(409);
262+
expect(w.body.error.code).toBe('SETTINGS_LOCKED');
263+
// Re-reads and the namespace payload do not repeat the line.
264+
await svc.get(c.ns, c.key, ADMIN());
265+
await svc.getNamespace(c.ns, ADMIN());
266+
267+
expect(warns).toHaveLength(1);
268+
expect(warns[0]).toContain(envName);
269+
expect(warns[0]).toContain(`'${c.ns}.${c.key}'`);
270+
expect(warns[0]).toContain(`(type '${c.type}')`);
271+
expect(warns[0]).toContain('It IS in force');
272+
// An environment variable's value never reaches the log line.
273+
expect(warns[0]).not.toContain(c.bad);
274+
expect(errors).toHaveLength(0);
275+
});
276+
277+
it(`${envName} (${c.type}): a well-formed override draws no warning`, async () => {
278+
const { svc, warns, errors } = makeStack({ [envName]: c.good });
279+
const r = await svc.get(c.ns, c.key, ADMIN());
280+
expect(r.value).toBe(c.good);
281+
expect(r.source).toBe('env');
282+
expect(warns).toHaveLength(0);
283+
expect(errors).toHaveLength(0);
284+
});
285+
}
286+
287+
it('a secret-bearing key: the override is in force, and its warning never carries the value', async () => {
288+
const { svc, warns } = makeStack({ OS_FORMAT_LAB_BILLING_EMAIL: SECRET_MALFORMED });
289+
const r = await svc.get('format_lab', 'billing_email', ADMIN());
290+
expect(r.value).toBe(SECRET_MALFORMED);
291+
expect(r.source).toBe('env');
292+
expect(warns).toHaveLength(1);
293+
expect(warns[0]).toContain('OS_FORMAT_LAB_BILLING_EMAIL');
294+
expect(warns[0]).toContain("'format_lab.billing_email'");
295+
expect(warns[0]).toContain("(type 'email')");
296+
expect(warns[0]).not.toContain(SECRET_MALFORMED);
297+
expect(warns[0]).not.toContain('billing-desk');
298+
expect(warns[0]).not.toContain('corp-internal');
299+
});
300+
301+
it('an override REJECTED by a refusal family is reported as rejected, not as a format miss', async () => {
302+
// `docs_url` declares `maxLength: 12`; a 16-character override is ignored
303+
// with the existing `error` line, and the format judgment never runs on a
304+
// value that is not in force.
305+
const { svc, warns, errors } = makeStack({ OS_FORMAT_LAB_DOCS_URL: 'not a url at all' });
306+
const r = await svc.get('format_lab', 'docs_url', ADMIN());
307+
expect(r.source).not.toBe('env');
308+
expect(errors).toHaveLength(1);
309+
expect(errors[0]).toContain('IGNORED');
310+
expect(warns).toEqual([]);
311+
});
312+
});

0 commit comments

Comments
 (0)