@vxil/feature-configs 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +145 -35
- package/dist/index.js +542 -45
- package/package.json +1 -1
- package/src/index.test.ts +544 -28
- package/src/index.ts +650 -102
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/feature-configs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "TypeBox config schemas for every vxil feature — the typed manifests vxil.config.ts is built on. INTERNAL workspace package: bundled into the published `vxil` package (via @vxil/config), not published separately.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://vxil.com",
|
package/src/index.test.ts
CHANGED
|
@@ -4,9 +4,14 @@ import {
|
|
|
4
4
|
CopilotConfigSchema,
|
|
5
5
|
FEATURE_SCHEMAS,
|
|
6
6
|
NotificationsConfigSchema,
|
|
7
|
+
PaymentsConfigSchema,
|
|
8
|
+
RESERVED_CREDIT_TYPES,
|
|
7
9
|
countLeaves,
|
|
10
|
+
isReservedCreditType,
|
|
8
11
|
setKnownMcpTools,
|
|
9
12
|
validateFeatureConfig,
|
|
13
|
+
NOTIF_OVERRIDE_MAX_BYTES, NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE,
|
|
14
|
+
validateNotificationOverrides,
|
|
10
15
|
} from './index.js';
|
|
11
16
|
// The LIVE mcp catalog (zero-import pure data) — the same relative-import
|
|
12
17
|
// convention as packages/cli/src/mcp.ts. Test-only: the publish build
|
|
@@ -14,8 +19,11 @@ import {
|
|
|
14
19
|
import { TOOLS } from '../../../workers/mcp-v1/src/tools.js';
|
|
15
20
|
|
|
16
21
|
describe('15-flag cap analyzer (architecture §6)', () => {
|
|
17
|
-
it('notifications schema has
|
|
18
|
-
|
|
22
|
+
it('notifications schema has 15 leaves — at the cap (retry + broadcast are Optional bags, M21)', () => {
|
|
23
|
+
// enabled, fromEmail, fromName, replyTo, resendApiKeyRef, webhookSecretRef,
|
|
24
|
+
// provider, defaultLocale, retry(bag=1), suppression.softBounceThreshold,
|
|
25
|
+
// rateLimit(2), templates.allowOverride, inboxEnabled, broadcast(bag=1) = 15.
|
|
26
|
+
expect(countLeaves(NotificationsConfigSchema)).toBe(15);
|
|
19
27
|
expect(countLeaves(NotificationsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
20
28
|
});
|
|
21
29
|
|
|
@@ -34,19 +42,45 @@ describe('15-flag cap analyzer (architecture §6)', () => {
|
|
|
34
42
|
}
|
|
35
43
|
});
|
|
36
44
|
|
|
37
|
-
it('auth schema
|
|
38
|
-
// enabled(1) + methods(6) + providers(1) + session(1)
|
|
39
|
-
// + emailVerification(1) + magicLink(1) + otp(1) + anonymous(1)
|
|
40
|
-
// + orgClaims(1) =
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(
|
|
45
|
+
it('auth schema is 11 leaves after D3 (methods collapsed to ONE optional bag; was 15 at the cap)', () => {
|
|
46
|
+
// enabled(1) + methods(1, optional bag — was 6) + providers(1) + session(1)
|
|
47
|
+
// + password(1) + emailVerification(1) + magicLink(1) + otp(1) + anonymous(1)
|
|
48
|
+
// + orgClaims(1) + security(1, optional bag — P0-3) = 11. The auth wave
|
|
49
|
+
// (2026-09-10, D3) funded itself by the methods collapse — the notifications
|
|
50
|
+
// retry/broadcast precedent; session.maxConcurrent / otp.testRecipients ride
|
|
51
|
+
// inside existing bags and never move the count.
|
|
52
|
+
expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(11);
|
|
45
53
|
expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
46
54
|
});
|
|
47
55
|
|
|
48
|
-
it('
|
|
49
|
-
|
|
56
|
+
it('D3 fold bytes: existing auth manifests fold BYTE-IDENTICALLY after the methods collapse', () => {
|
|
57
|
+
// The three inputs below were folded through the PRE-collapse schema
|
|
58
|
+
// (methods = a required Type.Object with { default: {} }) and the exact
|
|
59
|
+
// JSON captured; the collapse keeps `default: {}` on the now-Optional bag,
|
|
60
|
+
// so Value.Default still materializes every method flag in the same key
|
|
61
|
+
// order with the same defaults. Any drift here = a behavioural delta for a
|
|
62
|
+
// published tenant manifest (the worker reads config.methods.<flag>).
|
|
63
|
+
const CASES: Array<[unknown, string]> = [
|
|
64
|
+
[{},
|
|
65
|
+
'{"enabled":true,"methods":{"emailPassword":true,"magicLink":true,"google":false,"github":false,"apple":false,"facebook":false},"session":{"ttlMinutes":60,"refreshTtlDays":30},"password":{"minLength":8,"requireMixed":false},"emailVerification":{"required":false},"magicLink":{"tokenTtlMinutes":15}}'],
|
|
66
|
+
[{ methods: { google: true, github: true }, providers: { google: { clientIdRef: 'secret:g_id', clientSecretRef: 'secret:g_sec' } } },
|
|
67
|
+
'{"methods":{"google":true,"github":true,"emailPassword":true,"magicLink":true,"apple":false,"facebook":false},"providers":{"google":{"clientIdRef":"secret:g_id","clientSecretRef":"secret:g_sec"}},"enabled":true,"session":{"ttlMinutes":60,"refreshTtlDays":30},"password":{"minLength":8,"requireMixed":false},"emailVerification":{"required":false},"magicLink":{"tokenTtlMinutes":15}}'],
|
|
68
|
+
[{ enabled: true, methods: { emailPassword: false, magicLink: true, apple: true, facebook: false }, session: { ttlMinutes: 120 }, password: { minLength: 12, requireMixed: true }, emailVerification: { required: true }, magicLink: { tokenTtlMinutes: 30 }, otp: { enabled: true, maxAttempts: 7 }, anonymous: { enabled: true }, orgClaims: { enabled: true } },
|
|
69
|
+
'{"enabled":true,"methods":{"emailPassword":false,"magicLink":true,"apple":true,"facebook":false,"google":false,"github":false},"session":{"ttlMinutes":120,"refreshTtlDays":30},"password":{"minLength":12,"requireMixed":true},"emailVerification":{"required":true},"magicLink":{"tokenTtlMinutes":30},"otp":{"enabled":true,"maxAttempts":7,"codeTtlMinutes":10,"resendCooldownSec":60},"anonymous":{"enabled":true},"orgClaims":{"enabled":true}}'],
|
|
70
|
+
];
|
|
71
|
+
for (const [input, expected] of CASES) {
|
|
72
|
+
const v = validateFeatureConfig('auth', input);
|
|
73
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
74
|
+
expect(JSON.stringify(v.value)).toBe(expected);
|
|
75
|
+
}
|
|
76
|
+
// a manifest that omits methods entirely still folds to the full flag set
|
|
77
|
+
// (the worker never sees an absent bag on a published manifest)
|
|
78
|
+
const m = (validateFeatureConfig('auth', { enabled: true }).value as { methods: Record<string, boolean> }).methods;
|
|
79
|
+
expect(m).toEqual({ emailPassword: true, magicLink: true, google: false, github: false, apple: false, facebook: false });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('files schema is 11 leaves (F8-54 removed bucketRef + the contentScan bag)', () => {
|
|
83
|
+
expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBe(11);
|
|
50
84
|
expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
51
85
|
});
|
|
52
86
|
|
|
@@ -129,6 +163,74 @@ describe('validateFeatureConfig', () => {
|
|
|
129
163
|
expect(r.ok).toBe(false);
|
|
130
164
|
});
|
|
131
165
|
|
|
166
|
+
it('broadcast SURVIVES Value.Clean and persists via the real config path (M21/#4 regression)', () => {
|
|
167
|
+
// The HIGH this guards: `broadcast` used to live only in a worker-local
|
|
168
|
+
// schema, so this exact pipeline (Default → Clean → Check) silently
|
|
169
|
+
// DROPPED it and createCampaign 403'd on every real-config tenant.
|
|
170
|
+
const r = validateFeatureConfig('notifications', {
|
|
171
|
+
...minimal,
|
|
172
|
+
broadcast: { enabled: true },
|
|
173
|
+
});
|
|
174
|
+
expect(r.ok).toBe(true);
|
|
175
|
+
const v = r.value as {
|
|
176
|
+
broadcast?: { enabled: boolean; freqCapPerUserPerDay: number };
|
|
177
|
+
};
|
|
178
|
+
expect(v.broadcast).toBeDefined();
|
|
179
|
+
expect(v.broadcast!.enabled).toBe(true);
|
|
180
|
+
// leaf default materialized inside the present bag
|
|
181
|
+
expect(v.broadcast!.freqCapPerUserPerDay).toBe(5);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('broadcast.defaultQuietHours round-trips; absent broadcast stays absent (defaults-off deny)', () => {
|
|
185
|
+
const withQh = validateFeatureConfig('notifications', {
|
|
186
|
+
...minimal,
|
|
187
|
+
broadcast: {
|
|
188
|
+
enabled: true,
|
|
189
|
+
defaultQuietHours: { tz: 'UTC', start: '22:00', end: '08:00' },
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
expect(withQh.ok).toBe(true);
|
|
193
|
+
const v = withQh.value as {
|
|
194
|
+
broadcast?: { defaultQuietHours?: { tz: string; start: string; end: string } };
|
|
195
|
+
};
|
|
196
|
+
expect(v.broadcast!.defaultQuietHours).toEqual({ tz: 'UTC', start: '22:00', end: '08:00' });
|
|
197
|
+
|
|
198
|
+
const absent = validateFeatureConfig('notifications', minimal);
|
|
199
|
+
expect(absent.ok).toBe(true);
|
|
200
|
+
expect((absent.value as { broadcast?: unknown }).broadcast).toBeUndefined();
|
|
201
|
+
// while retry (Optional bag WITH default:{}) still materializes — the
|
|
202
|
+
// shape-preserving half of the M21 leaf-budget fund
|
|
203
|
+
expect(((absent.value as { retry?: { maxAttempts: number } }).retry ?? {}).maxAttempts).toBe(5);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('rejects a quiet-hours window parseHhMm cannot parse (would fail OPEN at run time)', () => {
|
|
207
|
+
const bad = validateFeatureConfig('notifications', {
|
|
208
|
+
...minimal,
|
|
209
|
+
broadcast: {
|
|
210
|
+
enabled: true,
|
|
211
|
+
defaultQuietHours: { tz: 'UTC', start: '10pm', end: '08:00' },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
expect(bad.ok).toBe(false);
|
|
215
|
+
expect(bad.errors.join(' ')).toContain('start');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('rejects a malformed broadcast bag (unknown sub-keys stripped; bad types fail)', () => {
|
|
219
|
+
const badType = validateFeatureConfig('notifications', {
|
|
220
|
+
...minimal,
|
|
221
|
+
broadcast: { enabled: 'yes' },
|
|
222
|
+
});
|
|
223
|
+
expect(badType.ok).toBe(false);
|
|
224
|
+
|
|
225
|
+
const sneaky = validateFeatureConfig('notifications', {
|
|
226
|
+
...minimal,
|
|
227
|
+
broadcast: { enabled: true, maxAudiencePerRun: 100000 },
|
|
228
|
+
});
|
|
229
|
+
expect(sneaky.ok).toBe(true); // unknown key CLEANED, not persisted
|
|
230
|
+
expect(Object.keys((sneaky.value as { broadcast: object }).broadcast))
|
|
231
|
+
.not.toContain('maxAudiencePerRun');
|
|
232
|
+
});
|
|
233
|
+
|
|
132
234
|
it('rejects an unknown feature', () => {
|
|
133
235
|
const r = validateFeatureConfig('telepathy', { enabled: true });
|
|
134
236
|
expect(r.ok).toBe(false);
|
|
@@ -298,6 +400,29 @@ describe('validateFeatureConfig', () => {
|
|
|
298
400
|
expect(bare.value).not.toHaveProperty('orgClaims');
|
|
299
401
|
});
|
|
300
402
|
|
|
403
|
+
it('auth: RB-2 providers.oidc round-trips, spends NO leaf, and rejects a bad issuer', () => {
|
|
404
|
+
const oidc = {
|
|
405
|
+
issuer: 'https://login.acme.example', clientId: 'acme', clientSecretRef: 'secret:oidc_client_secret',
|
|
406
|
+
scopes: ['openid', 'groups'], claims: { roles: 'groups' }, allowedDomains: ['acme.example'], autoLink: false,
|
|
407
|
+
};
|
|
408
|
+
const r = validateFeatureConfig('auth', { providers: { oidc } });
|
|
409
|
+
expect(r.ok, r.errors.join()).toBe(true);
|
|
410
|
+
expect((r.value as { providers: { oidc: unknown } }).providers.oidc).toEqual(oidc);
|
|
411
|
+
// the minimal block (presence = enabled) also round-trips, with NO defaults materialized
|
|
412
|
+
const min = validateFeatureConfig('auth', { providers: { oidc: { issuer: 'https://login.acme.example', clientId: 'a', clientSecretRef: 'secret:s' } } });
|
|
413
|
+
expect(min.ok).toBe(true);
|
|
414
|
+
expect((min.value as { providers: { oidc: Record<string, unknown> } }).providers.oidc).toEqual({ issuer: 'https://login.acme.example', clientId: 'a', clientSecretRef: 'secret:s' });
|
|
415
|
+
// still 11 leaves — the block lives INSIDE the providers bag
|
|
416
|
+
expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(11);
|
|
417
|
+
// issuer must be https with no query/fragment; domains lowercase dotted
|
|
418
|
+
for (const issuer of ['http://login.acme.example', 'https://login.acme.example/?x=1', 'https://a#b', 'login.acme.example']) {
|
|
419
|
+
expect(validateFeatureConfig('auth', { providers: { oidc: { issuer, clientId: 'a', clientSecretRef: 'secret:s' } } }).ok, issuer).toBe(false);
|
|
420
|
+
}
|
|
421
|
+
expect(validateFeatureConfig('auth', { providers: { oidc: { ...oidc, allowedDomains: ['ACME.example'] } } }).ok).toBe(false);
|
|
422
|
+
// a missing required field fails the pipeline (the worker would 501 anyway)
|
|
423
|
+
expect(validateFeatureConfig('auth', { providers: { oidc: { issuer: 'https://login.acme.example', clientId: 'a' } } }).ok).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
|
|
301
426
|
it('auth: otp knob bounds are enforced (maxAttempts 3..10)', () => {
|
|
302
427
|
expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 2 } }).ok).toBe(false);
|
|
303
428
|
expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 11 } }).ok).toBe(false);
|
|
@@ -312,9 +437,13 @@ describe('validateFeatureConfig', () => {
|
|
|
312
437
|
expect(okCfg.ok).toBe(true);
|
|
313
438
|
const explicit = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.created' }]));
|
|
314
439
|
expect(explicit.ok).toBe(true);
|
|
440
|
+
// F4-30: the CLOSED event union — every member accepted, anything else rejected
|
|
441
|
+
for (const event of ['session.created', 'session.revoked', 'signin.failure']) {
|
|
442
|
+
expect(validateFeatureConfig('functions', fn([{ kind: 'authHook', event }])).ok, event).toBe(true);
|
|
443
|
+
}
|
|
315
444
|
const bad = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.deleted' }]));
|
|
316
445
|
expect(bad.ok).toBe(false);
|
|
317
|
-
expect(bad.errors.join(' ')).toContain(
|
|
446
|
+
expect(bad.errors.join(' ')).toContain('user.created | session.created | session.revoked | signin.failure');
|
|
318
447
|
});
|
|
319
448
|
|
|
320
449
|
it('functions: DENY_FUNCTION_SCOPES rejects cross-tenant/self-integrity scopes but ALLOWS business scopes', () => {
|
|
@@ -375,8 +504,8 @@ describe('validateFeatureConfig', () => {
|
|
|
375
504
|
});
|
|
376
505
|
|
|
377
506
|
describe('mcp config: custom tools + prompts (mcp.md §6.5/§11)', () => {
|
|
378
|
-
it('McpConfigSchema
|
|
379
|
-
expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBe(
|
|
507
|
+
it('McpConfigSchema is 8 leaves (customTools/prompts are ONE Record leaf each; F8-54 removed scopedKey)', () => {
|
|
508
|
+
expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBe(8);
|
|
380
509
|
expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
381
510
|
});
|
|
382
511
|
|
|
@@ -489,8 +618,8 @@ describe('mcp config: custom tools + prompts (mcp.md §6.5/§11)', () => {
|
|
|
489
618
|
});
|
|
490
619
|
|
|
491
620
|
describe('copilot config (docs/copilot-agents-sku-design.md §4)', () => {
|
|
492
|
-
it('has exactly
|
|
493
|
-
expect(countLeaves(CopilotConfigSchema)).toBe(
|
|
621
|
+
it('has exactly 7 leaves — under the cap (agents/allow/collections are DATA)', () => {
|
|
622
|
+
expect(countLeaves(CopilotConfigSchema)).toBe(7);
|
|
494
623
|
expect(countLeaves(CopilotConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
495
624
|
});
|
|
496
625
|
|
|
@@ -575,19 +704,27 @@ describe('copilot config (docs/copilot-agents-sku-design.md §4)', () => {
|
|
|
575
704
|
expect(good.ok).toBe(true);
|
|
576
705
|
});
|
|
577
706
|
|
|
578
|
-
it(
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
expect(
|
|
584
|
-
|
|
707
|
+
it('the inert escalation bag + limits.tokensPerUserPerDay are GONE (F8-54)', () => {
|
|
708
|
+
const props = (CopilotConfigSchema as unknown as { properties: Record<string, unknown> }).properties;
|
|
709
|
+
expect(props['escalation']).toBeUndefined();
|
|
710
|
+
const limits = props['limits'] as { properties: Record<string, unknown> };
|
|
711
|
+
expect(limits.properties['tokensPerUserPerDay']).toBeUndefined();
|
|
712
|
+
expect(limits.properties['consumeCredits']).toBeDefined(); // the READ leaf stays
|
|
713
|
+
});
|
|
585
714
|
|
|
586
|
-
|
|
715
|
+
it('a PERSISTED manifest carrying the deleted copilot keys still folds (F8-54)', () => {
|
|
716
|
+
// The pre-F8-54 shape a live tenant may still have in config_versions/KV.
|
|
717
|
+
const r = validateFeatureConfig('copilot', {
|
|
718
|
+
enabled: true,
|
|
587
719
|
agents: {},
|
|
588
720
|
escalation: { enabled: true, handler: 'notifications', notifyTemplate: 'escalation' },
|
|
721
|
+
limits: { consumeCredits: true, tokensPerUserPerDay: 5000 },
|
|
589
722
|
});
|
|
590
|
-
expect(
|
|
723
|
+
expect(r.ok).toBe(true); // Value.Clean strips the unknown keys — never a 422
|
|
724
|
+
const v = r.value as { escalation?: unknown; limits: Record<string, unknown> };
|
|
725
|
+
expect(v.escalation).toBeUndefined();
|
|
726
|
+
expect(v.limits['tokensPerUserPerDay']).toBeUndefined();
|
|
727
|
+
expect(v.limits['consumeCredits']).toBe(true); // the surviving leaf is untouched
|
|
591
728
|
});
|
|
592
729
|
});
|
|
593
730
|
|
|
@@ -617,8 +754,8 @@ describe('functions config — signature (Level-1 typed I/O)', () => {
|
|
|
617
754
|
expect(r.errors.join(' ')).toContain('/functions/hello/signature');
|
|
618
755
|
});
|
|
619
756
|
|
|
620
|
-
it('functions schema
|
|
621
|
-
expect(countLeaves(FEATURE_SCHEMAS['functions']!)).toBe(
|
|
757
|
+
it('functions schema is 4 leaves (signature is DATA in the map; F8-54 removed runtime + 2 defaultLimits)', () => {
|
|
758
|
+
expect(countLeaves(FEATURE_SCHEMAS['functions']!)).toBe(4);
|
|
622
759
|
});
|
|
623
760
|
});
|
|
624
761
|
|
|
@@ -737,3 +874,382 @@ describe('cms-rel config: readModels + cdc (cms-relational-depth §3/§4)', () =
|
|
|
737
874
|
expect(validateFeatureConfig('cms', { cdc: bag }).ok).toBe(false);
|
|
738
875
|
});
|
|
739
876
|
});
|
|
877
|
+
|
|
878
|
+
// ── payments: reserved (vxil-COGS) credit type cannot be mapped in ledger (F2) ─
|
|
879
|
+
// A tenant that maps a product/tier grant to `fn_cpu_ms` and then triggers a
|
|
880
|
+
// webhook/subscription would credit the vxil-COGS meter type through the grant
|
|
881
|
+
// reducers → free vxil-billed functions. Rejected at config-write time so the
|
|
882
|
+
// tenant gets a clear `vxil push` error instead of a silent runtime skip.
|
|
883
|
+
describe('payments: reserved credit_type rejected in ledger grant maps (F2)', () => {
|
|
884
|
+
it('RESERVED_CREDIT_TYPES / isReservedCreditType are the shared SSOT', () => {
|
|
885
|
+
expect(RESERVED_CREDIT_TYPES.has('fn_cpu_ms')).toBe(true);
|
|
886
|
+
expect(isReservedCreditType('fn_cpu_ms')).toBe(true);
|
|
887
|
+
expect(isReservedCreditType('tokens')).toBe(false);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it('rejects fn_cpu_ms in productMap.creditType', () => {
|
|
891
|
+
const v = validateFeatureConfig('payments', {
|
|
892
|
+
ledger: {
|
|
893
|
+
productMap: { prod_free_cpu: { creditType: 'fn_cpu_ms', amount: 1000, period: 'once' } },
|
|
894
|
+
tierMap: {},
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
expect(v.ok).toBe(false);
|
|
898
|
+
expect(v.errors.some((e) => e.includes('/ledger/productMap/prod_free_cpu/creditType') && e.includes('reserved'))).toBe(true);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it('rejects fn_cpu_ms in a tierMap grant', () => {
|
|
902
|
+
const v = validateFeatureConfig('payments', {
|
|
903
|
+
ledger: {
|
|
904
|
+
productMap: {},
|
|
905
|
+
tierMap: {
|
|
906
|
+
pro: {
|
|
907
|
+
entitlements: [], quotas: {},
|
|
908
|
+
grants: [{ creditType: 'tokens', amount: 5, period: 'monthly' },
|
|
909
|
+
{ creditType: 'fn_cpu_ms', amount: 999_999, period: 'monthly' }],
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
});
|
|
914
|
+
expect(v.ok).toBe(false);
|
|
915
|
+
expect(v.errors.some((e) => e.includes('/ledger/tierMap/pro/grants/1/creditType') && e.includes('reserved'))).toBe(true);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it('accepts an ORDINARY credit_type through the same maps (no false-positive)', () => {
|
|
919
|
+
const v = validateFeatureConfig('payments', {
|
|
920
|
+
ledger: {
|
|
921
|
+
productMap: { pack_100: { creditType: 'tokens', amount: 100, period: 'once' } },
|
|
922
|
+
tierMap: { pro: { entitlements: ['pro'], quotas: {}, grants: [{ creditType: 'tokens', amount: 5, period: 'monthly' }] } },
|
|
923
|
+
},
|
|
924
|
+
});
|
|
925
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it('the reserved-type refinement is a VALIDATION rule, not a leaf — cap unaffected', () => {
|
|
929
|
+
// The refinement adds cross-field checks, never a schema property, so the
|
|
930
|
+
// payments leaf count is unchanged and comfortably under the cap.
|
|
931
|
+
expect(countLeaves(PaymentsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// ── payments: money-path wave (F1-3 config lint, F1-4 acceptSandbox, F1-7 grace,
|
|
936
|
+
// F3-19 forwardToTenantUrl deleted) ─────────────────────────────────────────
|
|
937
|
+
describe('payments: priceMap ⊆ tierMap + unique ranks (F1-3 / D3 config lint)', () => {
|
|
938
|
+
const base = {
|
|
939
|
+
tierMap: {
|
|
940
|
+
free: { entitlements: [], quotas: {}, rank: 0 },
|
|
941
|
+
pro: { entitlements: ['pro'], quotas: {}, rank: 10 },
|
|
942
|
+
},
|
|
943
|
+
productMap: {},
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
it('rejects a priceMap value that is not a tierMap key', () => {
|
|
947
|
+
const v = validateFeatureConfig('payments', {
|
|
948
|
+
ledger: { ...base, priceMap: { price_pro_monthly: 'pro', price_team: 'team' } },
|
|
949
|
+
});
|
|
950
|
+
expect(v.ok).toBe(false);
|
|
951
|
+
expect(v.errors.some((e) => e.includes('/ledger/priceMap/price_team') && e.includes("'team'"))).toBe(true);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it('accepts a priceMap whose every value is a tierMap key', () => {
|
|
955
|
+
const v = validateFeatureConfig('payments', {
|
|
956
|
+
ledger: { ...base, priceMap: { price_pro_monthly: 'pro', price_pro_yearly: 'pro' } },
|
|
957
|
+
});
|
|
958
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it('rejects two tiers declaring the SAME rank (fold winner would be order-dependent)', () => {
|
|
962
|
+
const v = validateFeatureConfig('payments', {
|
|
963
|
+
ledger: {
|
|
964
|
+
productMap: {},
|
|
965
|
+
tierMap: {
|
|
966
|
+
pro: { entitlements: ['pro'], quotas: {}, rank: 10 },
|
|
967
|
+
team: { entitlements: ['team'], quotas: {}, rank: 10 },
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
});
|
|
971
|
+
expect(v.ok).toBe(false);
|
|
972
|
+
expect(v.errors.some((e) => e.includes('/ledger/tierMap/team/rank') && e.includes('already used'))).toBe(true);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it('does NOT reject rank-less tiers (legacy configs keep pushing)', () => {
|
|
976
|
+
const v = validateFeatureConfig('payments', {
|
|
977
|
+
ledger: {
|
|
978
|
+
productMap: {},
|
|
979
|
+
tierMap: {
|
|
980
|
+
pro: { entitlements: ['pro'], quotas: {} },
|
|
981
|
+
team: { entitlements: ['team'], quotas: {} },
|
|
982
|
+
},
|
|
983
|
+
},
|
|
984
|
+
});
|
|
985
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
describe('payments: environment + grace knobs live INSIDE existing bags (leaf budget)', () => {
|
|
990
|
+
it('revenuecat.acceptSandbox defaults to false inside the revenuecat bag', () => {
|
|
991
|
+
const v = validateFeatureConfig('payments', {
|
|
992
|
+
provider: 'revenuecat',
|
|
993
|
+
revenuecat: { projectId: 'p', publicSdkKey: 'k', secretApiKeyRef: 'secret:rc' },
|
|
994
|
+
});
|
|
995
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
996
|
+
const rc = (v.value as { revenuecat: { acceptSandbox: boolean } }).revenuecat;
|
|
997
|
+
expect(rc.acceptSandbox).toBe(false);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it('ledger.grace.pastDueDays defaults to 0 when the bag is present, is bounded 0..90', () => {
|
|
1001
|
+
const ok = validateFeatureConfig('payments', {
|
|
1002
|
+
ledger: { productMap: {}, tierMap: {}, grace: {} },
|
|
1003
|
+
});
|
|
1004
|
+
expect(ok.ok, ok.errors.join()).toBe(true);
|
|
1005
|
+
expect((ok.value as { ledger: { grace: { pastDueDays: number } } }).ledger.grace.pastDueDays).toBe(0);
|
|
1006
|
+
expect(validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, grace: { pastDueDays: 91 } } }).ok).toBe(false);
|
|
1007
|
+
expect(validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, grace: { pastDueDays: -1 } } }).ok).toBe(false);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it('the inert webhooks.forwardToTenantUrl leaf is GONE (F3-19)', () => {
|
|
1011
|
+
const props = (PaymentsConfigSchema as unknown as { properties: Record<string, unknown> }).properties;
|
|
1012
|
+
expect(props['webhooks']).toBeUndefined();
|
|
1013
|
+
expect(JSON.stringify(PaymentsConfigSchema)).not.toContain('forwardToTenantUrl');
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it('PaymentsConfig sits at 10 leaves (was 11 — the deleted prices.catalogRef leaf is a net budget gain)', () => {
|
|
1017
|
+
// enabled, provider, stripe?, paddle?, revenuecat?, paypal?, returnUrl,
|
|
1018
|
+
// defaults.currency, defaults.trialDays, ledger? = 10. (grace and
|
|
1019
|
+
// enforcePeriodEnd are bags INSIDE the one optional ledger leaf.)
|
|
1020
|
+
expect(countLeaves(PaymentsConfigSchema)).toBe(10);
|
|
1021
|
+
expect(countLeaves(PaymentsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it('the inert prices.catalogRef leaf is GONE (F8-54) and a persisted manifest carrying it still folds', () => {
|
|
1025
|
+
const props = (PaymentsConfigSchema as unknown as { properties: Record<string, unknown> }).properties;
|
|
1026
|
+
expect(props['prices']).toBeUndefined();
|
|
1027
|
+
expect(JSON.stringify(PaymentsConfigSchema)).not.toContain('catalogRef');
|
|
1028
|
+
// the shipped manifests all carry `prices: { catalogRef: 'default' }` — Value.Clean strips it
|
|
1029
|
+
const v = validateFeatureConfig('payments', { provider: 'mock', prices: { catalogRef: 'default' }, ledger: { productMap: {}, tierMap: {} } });
|
|
1030
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
1031
|
+
expect((v.value as Record<string, unknown>).prices).toBeUndefined();
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it('ledger.enforcePeriodEnd (D2) is an optional bag inside ledger with a 72h default slack, bounded 0..720', () => {
|
|
1035
|
+
const on = validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, enforcePeriodEnd: {} } });
|
|
1036
|
+
expect(on.ok).toBe(true);
|
|
1037
|
+
expect((on.value as { ledger: { enforcePeriodEnd: { slackHours: number } } }).ledger.enforcePeriodEnd.slackHours).toBe(72);
|
|
1038
|
+
const off = validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {} } });
|
|
1039
|
+
expect((off.value as { ledger: { enforcePeriodEnd?: unknown } }).ledger.enforcePeriodEnd).toBeUndefined();
|
|
1040
|
+
expect(validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, enforcePeriodEnd: { slackHours: 721 } } }).ok).toBe(false);
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// ── notifications D4: per-locale template overrides ─────────────────────────
|
|
1045
|
+
describe('notifications: templates.overrides (D4 per-locale overrides)', () => {
|
|
1046
|
+
const base = { fromEmail: 'a@b.test', provider: 'mock' as const };
|
|
1047
|
+
const good = {
|
|
1048
|
+
welcome: {
|
|
1049
|
+
'fr-FR': {
|
|
1050
|
+
subject: 'Bienvenue sur {{app_name}} !',
|
|
1051
|
+
html: '<p>Salut{{first_name_greeting}} — {{app_name}}</p>',
|
|
1052
|
+
},
|
|
1053
|
+
},
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
it('accepts a well-formed override under allowOverride: true', () => {
|
|
1057
|
+
const v = validateFeatureConfig('notifications', {
|
|
1058
|
+
...base, templates: { allowOverride: true, overrides: good },
|
|
1059
|
+
});
|
|
1060
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it('REJECTS overrides when allowOverride is false — never silently inert', () => {
|
|
1064
|
+
const v = validateFeatureConfig('notifications', {
|
|
1065
|
+
...base, templates: { allowOverride: false, overrides: good },
|
|
1066
|
+
});
|
|
1067
|
+
expect(v.ok).toBe(false);
|
|
1068
|
+
expect(v.errors.join()).toContain('allowOverride');
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it('rejects an unknown template id', () => {
|
|
1072
|
+
const v = validateFeatureConfig('notifications', {
|
|
1073
|
+
...base,
|
|
1074
|
+
templates: { allowOverride: true, overrides: { invoice: { 'en-US': { subject: 'x' } } } },
|
|
1075
|
+
});
|
|
1076
|
+
expect(v.ok).toBe(false);
|
|
1077
|
+
expect(v.errors.join()).toContain('unknown template id');
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it('rejects an unknown placeholder — the typo that would render as an empty string', () => {
|
|
1081
|
+
const v = validateFeatureConfig('notifications', {
|
|
1082
|
+
...base,
|
|
1083
|
+
templates: {
|
|
1084
|
+
allowOverride: true,
|
|
1085
|
+
overrides: { welcome: { 'fr-FR': { subject: 'Salut {{app_nmae}}' } } },
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
expect(v.ok).toBe(false);
|
|
1089
|
+
expect(v.errors.join()).toContain("unknown placeholder '{{app_nmae}}'");
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
it('checks placeholders in html and text too, not just the subject', () => {
|
|
1093
|
+
for (const part of ['html', 'text'] as const) {
|
|
1094
|
+
const v = validateFeatureConfig('notifications', {
|
|
1095
|
+
...base,
|
|
1096
|
+
templates: {
|
|
1097
|
+
allowOverride: true,
|
|
1098
|
+
overrides: { welcome: { 'fr-FR': { subject: 'ok', [part]: '{{nope}}' } } },
|
|
1099
|
+
},
|
|
1100
|
+
});
|
|
1101
|
+
expect(v.ok, part).toBe(false);
|
|
1102
|
+
expect(v.errors.join()).toContain(`/${part}`);
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it('rejects a malformed locale tag', () => {
|
|
1107
|
+
const v = validateFeatureConfig('notifications', {
|
|
1108
|
+
...base,
|
|
1109
|
+
templates: { allowOverride: true, overrides: { welcome: { fr_FR: { subject: 'x' } } } },
|
|
1110
|
+
});
|
|
1111
|
+
expect(v.ok).toBe(false);
|
|
1112
|
+
expect(v.errors.join()).toContain('BCP-47');
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it('enforces the per-template locale cap and the whole-map byte cap', () => {
|
|
1116
|
+
const manyLocales: Record<string, { subject: string }> = {};
|
|
1117
|
+
for (let i = 0; i < NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE + 1; i++) {
|
|
1118
|
+
manyLocales[`en-x${i}`] = { subject: 'x' };
|
|
1119
|
+
}
|
|
1120
|
+
const capped = validateFeatureConfig('notifications', {
|
|
1121
|
+
...base, templates: { allowOverride: true, overrides: { welcome: manyLocales } },
|
|
1122
|
+
});
|
|
1123
|
+
expect(capped.ok).toBe(false);
|
|
1124
|
+
expect(capped.errors.join()).toContain('per-template cap');
|
|
1125
|
+
|
|
1126
|
+
const huge = validateFeatureConfig('notifications', {
|
|
1127
|
+
...base,
|
|
1128
|
+
templates: {
|
|
1129
|
+
allowOverride: true,
|
|
1130
|
+
overrides: {
|
|
1131
|
+
welcome: {
|
|
1132
|
+
'fr-FR': { subject: 'x', html: 'y'.repeat(19_000), text: 'z'.repeat(19_000) },
|
|
1133
|
+
'de-DE': { subject: 'x', html: 'y'.repeat(19_000), text: 'z'.repeat(19_000) },
|
|
1134
|
+
},
|
|
1135
|
+
},
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
expect(huge.ok).toBe(false);
|
|
1139
|
+
expect(huge.errors.join()).toContain(`${NOTIF_OVERRIDE_MAX_BYTES}-byte cap`);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it('the pure validator is [] for an absent/empty bag', () => {
|
|
1143
|
+
expect(validateNotificationOverrides(undefined)).toEqual([]);
|
|
1144
|
+
expect(validateNotificationOverrides({ allowOverride: true })).toEqual([]);
|
|
1145
|
+
expect(validateNotificationOverrides({ allowOverride: false, overrides: {} })).toEqual([]);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it('a legacy manifest with NO templates bag still validates and still materializes it', () => {
|
|
1149
|
+
const v = validateFeatureConfig('notifications', base);
|
|
1150
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
1151
|
+
// `default: {}` survives the Optional collapse — persisted manifests unchanged
|
|
1152
|
+
expect((v.value as { templates: { allowOverride: boolean } }).templates)
|
|
1153
|
+
.toEqual({ allowOverride: false });
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
it('NotificationsConfig stays AT the 15-leaf cap (overrides is a Record inside the ONE templates bag)', () => {
|
|
1157
|
+
expect(countLeaves(NotificationsConfigSchema)).toBe(15);
|
|
1158
|
+
expect(countLeaves(NotificationsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
// ── F8-54: inert declared knobs — wire or delete ─────────────────────────────
|
|
1163
|
+
// Six knobs shipped DECLARED but with no enforcing reader (register §8 / §12.4
|
|
1164
|
+
// B8); the programmatic sweep (scripts/config-leaf-readers.ts + the
|
|
1165
|
+
// tests/ci/src/config-leaf-readers.test.ts gate) found more. These assert the
|
|
1166
|
+
// DELETED half: the leaf is gone from the schema, the cap count moved, and a
|
|
1167
|
+
// manifest a live tenant already persisted with the old key STILL FOLDS —
|
|
1168
|
+
// Value.Clean strips unknown keys, so a deletion can never 422 an existing
|
|
1169
|
+
// tenant's next publish.
|
|
1170
|
+
describe('F8-54: the deleted inert leaves stay deleted, and old manifests still fold', () => {
|
|
1171
|
+
const props = (schema: unknown): Record<string, unknown> =>
|
|
1172
|
+
(schema as { properties: Record<string, unknown> }).properties;
|
|
1173
|
+
|
|
1174
|
+
it('files: bucketRef + the contentScan bag are gone (11 leaves, was 14)', () => {
|
|
1175
|
+
const p = props(FEATURE_SCHEMAS['files']!);
|
|
1176
|
+
expect(p['bucketRef']).toBeUndefined();
|
|
1177
|
+
expect(p['contentScan']).toBeUndefined();
|
|
1178
|
+
expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBe(11);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it('files: a persisted manifest with bucketRef + contentScan still folds', () => {
|
|
1182
|
+
const r = validateFeatureConfig('files', {
|
|
1183
|
+
enabled: true,
|
|
1184
|
+
bucketRef: 'vxil-files',
|
|
1185
|
+
contentScan: { enabled: true, quarantineOnFail: false },
|
|
1186
|
+
uploadUrlTtl: 1800,
|
|
1187
|
+
});
|
|
1188
|
+
expect(r.ok).toBe(true);
|
|
1189
|
+
const v = r.value as Record<string, unknown>;
|
|
1190
|
+
expect(v['bucketRef']).toBeUndefined();
|
|
1191
|
+
expect(v['contentScan']).toBeUndefined();
|
|
1192
|
+
expect(v['uploadUrlTtl']).toBe(1800); // the live leaves ride through untouched
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
it('mcp: scopedKey is gone (8 leaves, was 9) and an old manifest folds', () => {
|
|
1196
|
+
expect(props(FEATURE_SCHEMAS['mcp']!)['scopedKey']).toBeUndefined();
|
|
1197
|
+
expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBe(8);
|
|
1198
|
+
const r = validateFeatureConfig('mcp', {
|
|
1199
|
+
enabled: true, scopedKey: { perAgentKeys: true }, exposureLevel: 'read-only',
|
|
1200
|
+
});
|
|
1201
|
+
expect(r.ok).toBe(true);
|
|
1202
|
+
expect((r.value as Record<string, unknown>)['scopedKey']).toBeUndefined();
|
|
1203
|
+
expect((r.value as Record<string, unknown>)['exposureLevel']).toBe('read-only');
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it('activity-feed: the aggregation + rateLimit bags are gone (11 leaves, was 13)', () => {
|
|
1207
|
+
const p = props(FEATURE_SCHEMAS['activity-feed']!);
|
|
1208
|
+
expect(p['aggregation']).toBeUndefined();
|
|
1209
|
+
expect(p['rateLimit']).toBeUndefined();
|
|
1210
|
+
expect(countLeaves(FEATURE_SCHEMAS['activity-feed']!)).toBe(11);
|
|
1211
|
+
const r = validateFeatureConfig('activity-feed', {
|
|
1212
|
+
enabled: true,
|
|
1213
|
+
aggregation: { maxGroupActivities: 30 },
|
|
1214
|
+
rateLimit: { addPerSec: 500 },
|
|
1215
|
+
follow: { copyLimit: 42 },
|
|
1216
|
+
});
|
|
1217
|
+
expect(r.ok).toBe(true);
|
|
1218
|
+
const v = r.value as Record<string, unknown>;
|
|
1219
|
+
expect(v['aggregation']).toBeUndefined();
|
|
1220
|
+
expect(v['rateLimit']).toBeUndefined();
|
|
1221
|
+
expect((v['follow'] as Record<string, unknown>)['copyLimit']).toBe(42);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it('functions: runtime + defaultLimits.{timeoutMs,memoryMb} are gone (4 leaves, was 7)', () => {
|
|
1225
|
+
const p = props(FEATURE_SCHEMAS['functions']!);
|
|
1226
|
+
expect(p['runtime']).toBeUndefined();
|
|
1227
|
+
const dl = props(p['defaultLimits']);
|
|
1228
|
+
expect(dl['timeoutMs']).toBeUndefined();
|
|
1229
|
+
expect(dl['memoryMb']).toBeUndefined();
|
|
1230
|
+
expect(dl['cpuMs']).toBeDefined(); // the ONE enforced limit stays
|
|
1231
|
+
expect(countLeaves(FEATURE_SCHEMAS['functions']!)).toBe(4);
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
it('functions: a persisted manifest with runtime + the dead limits still folds', () => {
|
|
1235
|
+
const r = validateFeatureConfig('functions', {
|
|
1236
|
+
enabled: true,
|
|
1237
|
+
runtime: 'container',
|
|
1238
|
+
defaultLimits: { cpuMs: 120, timeoutMs: 30_000, memoryMb: 512 },
|
|
1239
|
+
metering: { enabled: true },
|
|
1240
|
+
});
|
|
1241
|
+
expect(r.ok).toBe(true);
|
|
1242
|
+
const v = r.value as Record<string, unknown>;
|
|
1243
|
+
expect(v['runtime']).toBeUndefined();
|
|
1244
|
+
const dl = v['defaultLimits'] as Record<string, unknown>;
|
|
1245
|
+
expect(dl['cpuMs']).toBe(120); // the enforced value survives
|
|
1246
|
+
expect(dl['timeoutMs']).toBeUndefined();
|
|
1247
|
+
expect(dl['memoryMb']).toBeUndefined();
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
it('every surviving schema still fits the cap (the deletions only freed budget)', () => {
|
|
1251
|
+
for (const [feature, schema] of Object.entries(FEATURE_SCHEMAS)) {
|
|
1252
|
+
expect(countLeaves(schema), `${feature} exceeds the flag cap`).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
});
|