@vxil/feature-configs 0.3.1 → 0.4.1
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 +19 -6
- package/dist/index.js +166 -16
- package/package.json +3 -2
- package/src/index.ts +173 -22
- package/src/hooks.test.ts +0 -451
- package/src/index.test.ts +0 -1255
package/src/index.test.ts
DELETED
|
@@ -1,1255 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
CONFIG_FLAG_CAP,
|
|
4
|
-
CopilotConfigSchema,
|
|
5
|
-
FEATURE_SCHEMAS,
|
|
6
|
-
NotificationsConfigSchema,
|
|
7
|
-
PaymentsConfigSchema,
|
|
8
|
-
RESERVED_CREDIT_TYPES,
|
|
9
|
-
countLeaves,
|
|
10
|
-
isReservedCreditType,
|
|
11
|
-
setKnownMcpTools,
|
|
12
|
-
validateFeatureConfig,
|
|
13
|
-
NOTIF_OVERRIDE_MAX_BYTES, NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE,
|
|
14
|
-
validateNotificationOverrides,
|
|
15
|
-
} from './index.js';
|
|
16
|
-
// The LIVE mcp catalog (zero-import pure data) — the same relative-import
|
|
17
|
-
// convention as packages/cli/src/mcp.ts. Test-only: the publish build
|
|
18
|
-
// (tsconfig.build.json) excludes tests, so rootDir stays clean.
|
|
19
|
-
import { TOOLS } from '../../../workers/mcp-v1/src/tools.js';
|
|
20
|
-
|
|
21
|
-
describe('15-flag cap analyzer (architecture §6)', () => {
|
|
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);
|
|
27
|
-
expect(countLeaves(NotificationsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('EVERY registered feature schema is ≤ 15 leaves (the CI gate)', () => {
|
|
31
|
-
for (const [feature, schema] of Object.entries(FEATURE_SCHEMAS)) {
|
|
32
|
-
expect(countLeaves(schema), `${feature} exceeds the flag cap`).toBeLessThanOrEqual(
|
|
33
|
-
CONFIG_FLAG_CAP,
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('every shipped feature has a registered config schema (typebox-config-required)', () => {
|
|
39
|
-
const SHIPPED_FEATURES = ['notifications', 'jobs', 'auth'];
|
|
40
|
-
for (const f of SHIPPED_FEATURES) {
|
|
41
|
-
expect(FEATURE_SCHEMAS[f], `feature '${f}' has no TypeBox config schema`).toBeDefined();
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
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);
|
|
53
|
-
expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
54
|
-
});
|
|
55
|
-
|
|
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);
|
|
84
|
-
expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('jobs schema is 13 leaves (generation gains reserve caps: +2 = 6 generation leaves)', () => {
|
|
88
|
-
// enabled(1) + retry(1) + retention(2) + dlqDailyQuota(1) + schedules(1)
|
|
89
|
-
// + concurrency(1) + generation(6: maxConcurrent, defaultTimeoutMs,
|
|
90
|
-
// maxTimeoutMs, pollMaxAttempts, maxReserveCredits, maxOutstandingReserveCredits)
|
|
91
|
-
// = 13, with 2 leaves of headroom under the cap.
|
|
92
|
-
expect(countLeaves(FEATURE_SCHEMAS['jobs']!)).toBe(13);
|
|
93
|
-
expect(countLeaves(FEATURE_SCHEMAS['jobs']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('vector-search schema is 14 leaves (rerank optional object +1, sync array +1)', () => {
|
|
97
|
-
expect(countLeaves(FEATURE_SCHEMAS['vector-search']!)).toBe(14);
|
|
98
|
-
expect(countLeaves(FEATURE_SCHEMAS['vector-search']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('ai schema is exactly 15 leaves — providers.compat (Optional object) counts as ONE; ZERO headroom', () => {
|
|
102
|
-
// enabled + defaultProvider + 3 keyRefs + compat(1) + defaults(3) + cache(1)
|
|
103
|
-
// + limits(2) + streaming(3) = 15. Any future ai knob must ride inside
|
|
104
|
-
// compat (free) or another optional object.
|
|
105
|
-
expect(countLeaves(FEATURE_SCHEMAS['ai']!)).toBe(15);
|
|
106
|
-
expect(countLeaves(FEATURE_SCHEMAS['ai']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe('ai config — compat.openaiBaseUrl write-time validation', () => {
|
|
111
|
-
it('accepts a public https base + an openrouter default provider', () => {
|
|
112
|
-
const v = validateFeatureConfig('ai', {
|
|
113
|
-
defaultProvider: 'openrouter',
|
|
114
|
-
providers: { compat: { openrouterKeyRef: 'secret:openrouter', openaiBaseUrl: 'https://api.deepseek.com/v1' } },
|
|
115
|
-
});
|
|
116
|
-
expect(v.ok).toBe(true);
|
|
117
|
-
const manifest = v.value as { providers: { compat?: { openrouterKeyRef?: string } } };
|
|
118
|
-
expect(manifest.providers.compat?.openrouterKeyRef).toBe('secret:openrouter');
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('rejects http://, private-IP and internal-host bases before they can publish', () => {
|
|
122
|
-
for (const bad of ['http://api.example.com/v1', 'https://10.1.2.3/v1', 'https://metadata.google.internal/v1', 'https://localhost:8080/v1']) {
|
|
123
|
-
const v = validateFeatureConfig('ai', { providers: { compat: { openaiBaseUrl: bad } } });
|
|
124
|
-
expect(v.ok, bad).toBe(false);
|
|
125
|
-
expect(v.errors?.[0]).toContain('/providers/compat/openaiBaseUrl');
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('defaults leave compat absent (the optional-object leaf is free until used)', () => {
|
|
130
|
-
const v = validateFeatureConfig('ai', {});
|
|
131
|
-
expect(v.ok).toBe(true);
|
|
132
|
-
expect((v.value as { providers: { compat?: unknown } }).providers.compat).toBeUndefined();
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
describe('validateFeatureConfig', () => {
|
|
137
|
-
const minimal = {
|
|
138
|
-
fromEmail: 'noreply@acme.com',
|
|
139
|
-
resendApiKeyRef: 'secret:resend_api_key',
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
it('accepts a minimal config and applies defaults', () => {
|
|
143
|
-
const r = validateFeatureConfig('notifications', minimal);
|
|
144
|
-
expect(r.ok).toBe(true);
|
|
145
|
-
const v = r.value as Record<string, unknown>;
|
|
146
|
-
expect(v.enabled).toBe(true);
|
|
147
|
-
expect(v.provider).toBe('resend');
|
|
148
|
-
expect((v.retry as Record<string, unknown>).maxAttempts).toBe(5);
|
|
149
|
-
expect((v.rateLimit as Record<string, unknown>).perTenantSec).toBe(50);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('rejects a malformed fromEmail', () => {
|
|
153
|
-
const r = validateFeatureConfig('notifications', { ...minimal, fromEmail: 'not-an-email' });
|
|
154
|
-
expect(r.ok).toBe(false);
|
|
155
|
-
expect(r.errors.join(' ')).toContain('fromEmail');
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('rejects retry.maxAttempts out of range', () => {
|
|
159
|
-
const r = validateFeatureConfig('notifications', {
|
|
160
|
-
...minimal,
|
|
161
|
-
retry: { maxAttempts: 99, backoff: 'exponential' },
|
|
162
|
-
});
|
|
163
|
-
expect(r.ok).toBe(false);
|
|
164
|
-
});
|
|
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
|
-
|
|
234
|
-
it('rejects an unknown feature', () => {
|
|
235
|
-
const r = validateFeatureConfig('telepathy', { enabled: true });
|
|
236
|
-
expect(r.ok).toBe(false);
|
|
237
|
-
expect(r.errors[0]).toContain('unknown feature');
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('rejects an unknown provider', () => {
|
|
241
|
-
const r = validateFeatureConfig('notifications', { ...minimal, provider: 'sendgrid' });
|
|
242
|
-
expect(r.ok).toBe(false);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it("provider 'mock' needs no resendApiKeyRef (zero-config); provider 'resend' still requires it", () => {
|
|
246
|
-
const mock = validateFeatureConfig('notifications', {
|
|
247
|
-
fromEmail: 'noreply@acme.com', provider: 'mock', inboxEnabled: true,
|
|
248
|
-
});
|
|
249
|
-
expect(mock.ok).toBe(true);
|
|
250
|
-
|
|
251
|
-
const resendNoRef = validateFeatureConfig('notifications', {
|
|
252
|
-
fromEmail: 'noreply@acme.com', provider: 'resend',
|
|
253
|
-
});
|
|
254
|
-
expect(resendNoRef.ok).toBe(false);
|
|
255
|
-
expect(resendNoRef.errors.join(' ')).toContain('resendApiKeyRef');
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it('strips unknown top-level AND nested keys so they are never persisted (audit #112/#118)', () => {
|
|
259
|
-
const r = validateFeatureConfig('notifications', {
|
|
260
|
-
...minimal,
|
|
261
|
-
bogusTopLevel: 'evil',
|
|
262
|
-
retry: { maxAttempts: 3, backoff: 'linear', sneaky: 'nope' },
|
|
263
|
-
});
|
|
264
|
-
expect(r.ok).toBe(true);
|
|
265
|
-
const v = r.value as Record<string, unknown>;
|
|
266
|
-
// declared keys survive (incl. materialized defaults)
|
|
267
|
-
expect(v.fromEmail).toBe('noreply@acme.com');
|
|
268
|
-
expect((v.retry as Record<string, unknown>).maxAttempts).toBe(3);
|
|
269
|
-
// unknown keys at every depth are removed — the stored manifest is closed
|
|
270
|
-
expect(v).not.toHaveProperty('bogusTopLevel');
|
|
271
|
-
expect(v.retry).not.toHaveProperty('sneaky');
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it('auth: accepts apple + facebook provider config blocks + method toggles (§6.4)', () => {
|
|
275
|
-
const r = validateFeatureConfig('auth', {
|
|
276
|
-
methods: { google: false, github: false, apple: true, facebook: true },
|
|
277
|
-
providers: {
|
|
278
|
-
apple: {
|
|
279
|
-
servicesId: 'com.acme.web',
|
|
280
|
-
teamId: 'TEAM123456',
|
|
281
|
-
keyId: 'KEY7890AB',
|
|
282
|
-
p8KeyRef: 'secret:apple_signin_p8',
|
|
283
|
-
},
|
|
284
|
-
facebook: {
|
|
285
|
-
clientIdRef: 'secret:facebook_app_id',
|
|
286
|
-
clientSecretRef: 'secret:facebook_app_secret',
|
|
287
|
-
},
|
|
288
|
-
},
|
|
289
|
-
});
|
|
290
|
-
expect(r.ok).toBe(true);
|
|
291
|
-
const v = r.value as { providers?: Record<string, Record<string, unknown>> };
|
|
292
|
-
// the apple/facebook BYO refs survive Value.Clean (declared → persisted)
|
|
293
|
-
expect(v.providers?.apple).toEqual({
|
|
294
|
-
servicesId: 'com.acme.web',
|
|
295
|
-
teamId: 'TEAM123456',
|
|
296
|
-
keyId: 'KEY7890AB',
|
|
297
|
-
p8KeyRef: 'secret:apple_signin_p8',
|
|
298
|
-
});
|
|
299
|
-
expect(v.providers?.facebook).toEqual({
|
|
300
|
-
clientIdRef: 'secret:facebook_app_id',
|
|
301
|
-
clientSecretRef: 'secret:facebook_app_secret',
|
|
302
|
-
});
|
|
303
|
-
// The enable toggles survive Value.Clean — the regression that made the
|
|
304
|
-
// shipped oauthCore apple/facebook flow un-enablable via the pipeline.
|
|
305
|
-
const m = (r.value as { methods: Record<string, boolean> }).methods;
|
|
306
|
-
expect(m.apple).toBe(true);
|
|
307
|
-
expect(m.facebook).toBe(true);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it('files: extractText + ttl round-trip WITHOUT being stripped (the Value.Clean regression)', () => {
|
|
311
|
-
const r = validateFeatureConfig('files', {
|
|
312
|
-
extractText: { enabled: true, provider: 'gcv', keyRef: 'secret:ocr_api_key' },
|
|
313
|
-
ttl: { enabled: true, defaultExpiresInSeconds: 3600 },
|
|
314
|
-
});
|
|
315
|
-
expect(r.ok).toBe(true);
|
|
316
|
-
const v = r.value as {
|
|
317
|
-
extractText?: Record<string, unknown>;
|
|
318
|
-
ttl?: Record<string, unknown>;
|
|
319
|
-
};
|
|
320
|
-
expect(v.extractText).toMatchObject({
|
|
321
|
-
enabled: true,
|
|
322
|
-
provider: 'gcv',
|
|
323
|
-
keyRef: 'secret:ocr_api_key',
|
|
324
|
-
// inner defaults materialize on a provided bag
|
|
325
|
-
asyncOverJobs: true,
|
|
326
|
-
boundingBoxes: false,
|
|
327
|
-
});
|
|
328
|
-
expect(v.ttl).toMatchObject({
|
|
329
|
-
enabled: true,
|
|
330
|
-
defaultExpiresInSeconds: 3600,
|
|
331
|
-
sweepCron: '0 * * * *',
|
|
332
|
-
});
|
|
333
|
-
// omitted bags stay omitted (optional — no default:{} materialization)
|
|
334
|
-
const bare = validateFeatureConfig('files', { enabled: true });
|
|
335
|
-
expect(bare.ok).toBe(true);
|
|
336
|
-
expect(bare.value).not.toHaveProperty('extractText');
|
|
337
|
-
expect(bare.value).not.toHaveProperty('ttl');
|
|
338
|
-
// unknown providers are rejected, not silently coerced
|
|
339
|
-
const bad = validateFeatureConfig('files', { extractText: { enabled: true, provider: 'tesseract' } });
|
|
340
|
-
expect(bad.ok).toBe(false);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it('auth: facebook accepts optional audRefs (extra native-aud allow-list)', () => {
|
|
344
|
-
const r = validateFeatureConfig('auth', {
|
|
345
|
-
providers: {
|
|
346
|
-
facebook: {
|
|
347
|
-
clientIdRef: 'secret:fb_app_id',
|
|
348
|
-
clientSecretRef: 'secret:fb_app_secret',
|
|
349
|
-
audRefs: ['secret:fb_ios_client', 'secret:fb_android_client'],
|
|
350
|
-
},
|
|
351
|
-
},
|
|
352
|
-
});
|
|
353
|
-
expect(r.ok).toBe(true);
|
|
354
|
-
const v = r.value as { providers?: { facebook?: { audRefs?: string[] } } };
|
|
355
|
-
expect(v.providers?.facebook?.audRefs).toEqual(['secret:fb_ios_client', 'secret:fb_android_client']);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it('auth: apple bundleIds round-trip through validateFeatureConfig (native iOS aud)', () => {
|
|
359
|
-
// Regression: before the schema carried bundleIds, Value.Clean STRIPPED the
|
|
360
|
-
// field on publish, so genuine iOS ASAuthorization id_tokens (aud = bundle
|
|
361
|
-
// id) could never be allow-listed via the pipeline (the worker already
|
|
362
|
-
// reads providers.apple.bundleIds — oauthCore.ts).
|
|
363
|
-
const r = validateFeatureConfig('auth', {
|
|
364
|
-
providers: {
|
|
365
|
-
apple: {
|
|
366
|
-
servicesId: 'com.acme.web', teamId: 'TEAM123456', keyId: 'KEY7890AB',
|
|
367
|
-
p8KeyRef: 'secret:apple_p8', bundleIds: ['com.acme.app', 'com.acme.app.dev'],
|
|
368
|
-
},
|
|
369
|
-
},
|
|
370
|
-
});
|
|
371
|
-
expect(r.ok).toBe(true);
|
|
372
|
-
const v = r.value as { providers?: { apple?: { bundleIds?: string[] } } };
|
|
373
|
-
expect(v.providers?.apple?.bundleIds).toEqual(['com.acme.app', 'com.acme.app.dev']);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
it('auth: otp/anonymous/orgClaims optional bags round-trip; session/password stay materialized', () => {
|
|
377
|
-
const r = validateFeatureConfig('auth', {
|
|
378
|
-
otp: { enabled: true, codeTtlMinutes: 5 },
|
|
379
|
-
anonymous: { enabled: true },
|
|
380
|
-
orgClaims: { enabled: true },
|
|
381
|
-
});
|
|
382
|
-
expect(r.ok).toBe(true);
|
|
383
|
-
const v = r.value as Record<string, unknown>;
|
|
384
|
-
// provided bags survive Value.Clean with inner defaults materialized
|
|
385
|
-
expect(v.otp).toEqual({ enabled: true, codeTtlMinutes: 5, maxAttempts: 5, resendCooldownSec: 60 });
|
|
386
|
-
expect(v.anonymous).toEqual({ enabled: true });
|
|
387
|
-
expect(v.orgClaims).toEqual({ enabled: true });
|
|
388
|
-
// session/password are optional bags but keep `{ default: {} }`, so a bare
|
|
389
|
-
// publish still materializes the knobs the worker reads (back-compat).
|
|
390
|
-
expect(v.session).toEqual({ ttlMinutes: 60, refreshTtlDays: 30 });
|
|
391
|
-
expect(v.password).toEqual({ minLength: 8, requireMixed: false });
|
|
392
|
-
// tokenTtlHours is GONE (consumer-less — dropped for cap headroom)
|
|
393
|
-
expect(v.emailVerification).toEqual({ required: false });
|
|
394
|
-
|
|
395
|
-
// omitted new bags stay omitted (absent = disabled at the worker gate)
|
|
396
|
-
const bare = validateFeatureConfig('auth', {});
|
|
397
|
-
expect(bare.ok).toBe(true);
|
|
398
|
-
expect(bare.value).not.toHaveProperty('otp');
|
|
399
|
-
expect(bare.value).not.toHaveProperty('anonymous');
|
|
400
|
-
expect(bare.value).not.toHaveProperty('orgClaims');
|
|
401
|
-
});
|
|
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
|
-
|
|
426
|
-
it('auth: otp knob bounds are enforced (maxAttempts 3..10)', () => {
|
|
427
|
-
expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 2 } }).ok).toBe(false);
|
|
428
|
-
expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 11 } }).ok).toBe(false);
|
|
429
|
-
expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 10 } }).ok).toBe(true);
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
it('functions: an authHook binding is accepted; a bad event is rejected', () => {
|
|
433
|
-
const fn = (bindings: unknown[]) => ({
|
|
434
|
-
functions: { provision: { scriptRef: 'fn-t-provision-abc', bindings } },
|
|
435
|
-
});
|
|
436
|
-
const okCfg = validateFeatureConfig('functions', fn([{ kind: 'authHook' }]));
|
|
437
|
-
expect(okCfg.ok).toBe(true);
|
|
438
|
-
const explicit = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.created' }]));
|
|
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
|
-
}
|
|
444
|
-
const bad = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.deleted' }]));
|
|
445
|
-
expect(bad.ok).toBe(false);
|
|
446
|
-
expect(bad.errors.join(' ')).toContain('user.created | session.created | session.revoked | signin.failure');
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
it('functions: DENY_FUNCTION_SCOPES rejects cross-tenant/self-integrity scopes but ALLOWS business scopes', () => {
|
|
450
|
-
const fn = (scopes: string[]) => ({ functions: { f: { scriptRef: 'fn-t-f-abc', scopes } } });
|
|
451
|
-
// ALLOWED — legitimate function business actions (checkout saga, email, user
|
|
452
|
-
// mgmt, fn→fn composition).
|
|
453
|
-
for (const s of ['payments:write', 'notifications:send', 'users:write', 'users:read', 'cms:write', 'functions:invoke']) {
|
|
454
|
-
expect(validateFeatureConfig('functions', fn([s])).ok, `expected '${s}' allowed`).toBe(true);
|
|
455
|
-
}
|
|
456
|
-
// DENIED — cross-tenant (admin/*) + self-integrity (features:write/functions:write/secrets:write).
|
|
457
|
-
for (const s of ['admin', '*', 'features:write', 'functions:write', 'secrets:write']) {
|
|
458
|
-
const r = validateFeatureConfig('functions', fn([s]));
|
|
459
|
-
expect(r.ok, `expected '${s}' denied`).toBe(false);
|
|
460
|
-
expect(r.errors.join(' ')).toContain('never grantable');
|
|
461
|
-
}
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
it('auth: rejects an apple block missing a required field (teamId)', () => {
|
|
465
|
-
const r = validateFeatureConfig('auth', {
|
|
466
|
-
providers: {
|
|
467
|
-
apple: { servicesId: 'com.acme.web', keyId: 'KEY7890AB', p8KeyRef: 'secret:apple_p8' },
|
|
468
|
-
},
|
|
469
|
-
});
|
|
470
|
-
expect(r.ok).toBe(false);
|
|
471
|
-
expect(r.errors.join(' ')).toContain('teamId');
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
it('auth: strips an unknown provider key (config is closed — only the 4 known providers)', () => {
|
|
475
|
-
const r = validateFeatureConfig('auth', {
|
|
476
|
-
providers: {
|
|
477
|
-
google: { clientIdRef: 'secret:g_id', clientSecretRef: 'secret:g_secret' },
|
|
478
|
-
twitter: { clientIdRef: 'secret:x_id', clientSecretRef: 'secret:x_secret' },
|
|
479
|
-
},
|
|
480
|
-
});
|
|
481
|
-
expect(r.ok).toBe(true);
|
|
482
|
-
const v = r.value as { providers?: Record<string, unknown> };
|
|
483
|
-
expect(v.providers).toHaveProperty('google');
|
|
484
|
-
expect(v.providers).not.toHaveProperty('twitter');
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
it('a non-schema key named like a prototype pollutant is dropped, not persisted', () => {
|
|
488
|
-
// Closing the schema (Value.Clean) means an attacker-supplied own key —
|
|
489
|
-
// including an own enumerable "__proto__" string key smuggled via JSON —
|
|
490
|
-
// never reaches the stored manifest. (Deep-merge proto-pollution itself is
|
|
491
|
-
// guarded in the control-plane merge layer; this asserts the validator
|
|
492
|
-
// doesn't widen the persisted object.)
|
|
493
|
-
const raw = JSON.parse(`{"fromEmail":"a@b.c","resendApiKeyRef":"r","__proto__":{"polluted":true}}`) as Record<string, unknown>;
|
|
494
|
-
const r = validateFeatureConfig('notifications', raw);
|
|
495
|
-
expect(r.ok).toBe(true);
|
|
496
|
-
// The persisted manifest is its OWN enumerable keys (what csql.json /
|
|
497
|
-
// JSON.stringify serialize) — none of them is the smuggled pollutant.
|
|
498
|
-
const ownKeys = Object.keys(r.value as object);
|
|
499
|
-
expect(ownKeys).not.toContain('__proto__');
|
|
500
|
-
expect(ownKeys).not.toContain('polluted');
|
|
501
|
-
expect(Object.prototype.hasOwnProperty.call(r.value, 'polluted')).toBe(false);
|
|
502
|
-
expect(JSON.parse(JSON.stringify(r.value)) as Record<string, unknown>).not.toHaveProperty('polluted');
|
|
503
|
-
});
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
describe('mcp config: custom tools + prompts (mcp.md §6.5/§11)', () => {
|
|
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);
|
|
509
|
-
expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
const tool = (over: Record<string, unknown> = {}) => ({
|
|
513
|
-
customTools: {
|
|
514
|
-
crm_lookup: {
|
|
515
|
-
description: 'Look up a CRM contact by email.',
|
|
516
|
-
url: 'https://api.acme.example/mcp/crm-lookup',
|
|
517
|
-
...over,
|
|
518
|
-
},
|
|
519
|
-
},
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
it('accepts a well-formed custom tool (config round-trips)', () => {
|
|
523
|
-
const r = validateFeatureConfig('mcp', tool({
|
|
524
|
-
inputSchema: { type: 'object', properties: { email: { type: 'string' } } },
|
|
525
|
-
timeoutMs: 5000,
|
|
526
|
-
}));
|
|
527
|
-
expect(r.ok).toBe(true);
|
|
528
|
-
const v = r.value as { customTools: Record<string, { url: string; timeoutMs: number }> };
|
|
529
|
-
expect(v.customTools.crm_lookup!.url).toBe('https://api.acme.example/mcp/crm-lookup');
|
|
530
|
-
expect(v.customTools.crm_lookup!.timeoutMs).toBe(5000);
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
it('rejects a bad tool name, an http:// URL, and internal/private hosts', () => {
|
|
534
|
-
const badName = validateFeatureConfig('mcp', {
|
|
535
|
-
customTools: { 'Bad-Name': { description: 'x', url: 'https://ok.example/a' } },
|
|
536
|
-
});
|
|
537
|
-
expect(badName.ok).toBe(false);
|
|
538
|
-
expect(badName.errors.join(' ')).toContain('name must match');
|
|
539
|
-
|
|
540
|
-
for (const url of [
|
|
541
|
-
'http://api.acme.example/hook', // scheme
|
|
542
|
-
'https://user:pass@api.acme.example/hook', // embedded creds
|
|
543
|
-
'https://localhost/hook',
|
|
544
|
-
'https://foo.localhost/hook',
|
|
545
|
-
'https://db.internal/hook',
|
|
546
|
-
'https://printer.local/hook',
|
|
547
|
-
'https://10.1.2.3/hook',
|
|
548
|
-
'https://169.254.169.254/latest/meta-data',
|
|
549
|
-
'https://[::1]/hook',
|
|
550
|
-
'not a url',
|
|
551
|
-
]) {
|
|
552
|
-
const r = validateFeatureConfig('mcp', tool({ url }));
|
|
553
|
-
expect(r.ok, `should reject ${url}`).toBe(false);
|
|
554
|
-
expect(r.errors.join(' ')).toContain('/customTools/crm_lookup/url');
|
|
555
|
-
}
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
it('rejects >32 custom tools and an oversized inputSchema', () => {
|
|
559
|
-
const many: Record<string, unknown> = {};
|
|
560
|
-
for (let i = 0; i < 33; i++) {
|
|
561
|
-
many[`tool_${i}`] = { description: 'x', url: 'https://ok.example/t' };
|
|
562
|
-
}
|
|
563
|
-
const over = validateFeatureConfig('mcp', { customTools: many });
|
|
564
|
-
expect(over.ok).toBe(false);
|
|
565
|
-
expect(over.errors.join(' ')).toContain('at most 32');
|
|
566
|
-
|
|
567
|
-
const fat = validateFeatureConfig('mcp', tool({
|
|
568
|
-
inputSchema: { type: 'object', description: 'x'.repeat(9000) },
|
|
569
|
-
}));
|
|
570
|
-
expect(fat.ok).toBe(false);
|
|
571
|
-
expect(fat.errors.join(' ')).toContain('8KB');
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
it('prompts: placeholders must be declared arguments; declared-but-unused is fine', () => {
|
|
575
|
-
const good = validateFeatureConfig('mcp', {
|
|
576
|
-
prompts: {
|
|
577
|
-
draft_reply: {
|
|
578
|
-
description: 'Draft a support reply.',
|
|
579
|
-
template: 'Reply to {{customer}} about {{topic}}. Be concise.',
|
|
580
|
-
arguments: [
|
|
581
|
-
{ name: 'customer', required: true },
|
|
582
|
-
{ name: 'topic' },
|
|
583
|
-
{ name: 'unused_extra' }, // allowed
|
|
584
|
-
],
|
|
585
|
-
},
|
|
586
|
-
},
|
|
587
|
-
});
|
|
588
|
-
expect(good.ok).toBe(true);
|
|
589
|
-
|
|
590
|
-
const bad = validateFeatureConfig('mcp', {
|
|
591
|
-
prompts: {
|
|
592
|
-
draft_reply: { template: 'Reply to {{customer}}.', arguments: [{ name: 'topic' }] },
|
|
593
|
-
},
|
|
594
|
-
});
|
|
595
|
-
expect(bad.ok).toBe(false);
|
|
596
|
-
expect(bad.errors.join(' ')).toContain('{{customer}}');
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
it('prompts: rejects a bad name and >32 prompts', () => {
|
|
600
|
-
const badName = validateFeatureConfig('mcp', {
|
|
601
|
-
prompts: { 'Bad Name': { template: 'x' } },
|
|
602
|
-
});
|
|
603
|
-
expect(badName.ok).toBe(false);
|
|
604
|
-
|
|
605
|
-
const many: Record<string, unknown> = {};
|
|
606
|
-
for (let i = 0; i < 33; i++) many[`p_${i}`] = { template: 'x' };
|
|
607
|
-
const over = validateFeatureConfig('mcp', { prompts: many });
|
|
608
|
-
expect(over.ok).toBe(false);
|
|
609
|
-
expect(over.errors.join(' ')).toContain('at most 32');
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
it('a bare mcp config (no customTools/prompts) still validates with defaults', () => {
|
|
613
|
-
const r = validateFeatureConfig('mcp', { enabled: true });
|
|
614
|
-
expect(r.ok).toBe(true);
|
|
615
|
-
expect(r.value).not.toHaveProperty('customTools');
|
|
616
|
-
expect(r.value).not.toHaveProperty('prompts');
|
|
617
|
-
});
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
describe('copilot config (docs/copilot-agents-sku-design.md §4)', () => {
|
|
621
|
-
it('has exactly 7 leaves — under the cap (agents/allow/collections are DATA)', () => {
|
|
622
|
-
expect(countLeaves(CopilotConfigSchema)).toBe(7);
|
|
623
|
-
expect(countLeaves(CopilotConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
624
|
-
});
|
|
625
|
-
|
|
626
|
-
it('quickstart minimal body materializes a valid zero-agent manifest', () => {
|
|
627
|
-
const r = validateFeatureConfig('copilot', { enabled: true });
|
|
628
|
-
expect(r.ok).toBe(true);
|
|
629
|
-
const v = r.value as Record<string, unknown>;
|
|
630
|
-
expect(v.agents).toEqual({});
|
|
631
|
-
expect((v.widget as Record<string, unknown>).enabled).toBe(false);
|
|
632
|
-
expect((v.limits as Record<string, unknown>).consumeCredits).toBe(false);
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
// NOTE: runs BEFORE the setKnownMcpTools registration below (vitest executes
|
|
636
|
-
// tests in file order) — asserts the published-package posture where no
|
|
637
|
-
// catalog is available and the name-existence rule is skipped.
|
|
638
|
-
it('without a registered catalog, unknown tool names pass (name rule skipped)', () => {
|
|
639
|
-
const r = validateFeatureConfig('copilot', {
|
|
640
|
-
agents: { a: { name: 'A', actions: { mode: 'actions', allow: { not_a_tool: {} } } } },
|
|
641
|
-
});
|
|
642
|
-
expect(r.ok).toBe(true);
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
it('with the live catalog registered, an unknown allow key is rejected; real names pass', () => {
|
|
646
|
-
setKnownMcpTools(TOOLS.map((t) => t.name));
|
|
647
|
-
const bad = validateFeatureConfig('copilot', {
|
|
648
|
-
agents: { a: { name: 'A', actions: { mode: 'actions', allow: { not_a_tool: {} } } } },
|
|
649
|
-
});
|
|
650
|
-
expect(bad.ok).toBe(false);
|
|
651
|
-
expect(bad.errors.join(' ')).toContain('not_a_tool');
|
|
652
|
-
|
|
653
|
-
const good = validateFeatureConfig('copilot', {
|
|
654
|
-
agents: {
|
|
655
|
-
a: {
|
|
656
|
-
name: 'A',
|
|
657
|
-
actions: { mode: 'actions', allow: { cms_query_items: {}, cms_create_item: { requireConfirm: true } } },
|
|
658
|
-
},
|
|
659
|
-
},
|
|
660
|
-
});
|
|
661
|
-
expect(good.ok).toBe(true);
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
it('guestToolAllow must be a subset of actions.allow', () => {
|
|
665
|
-
const r = validateFeatureConfig('copilot', {
|
|
666
|
-
agents: {
|
|
667
|
-
a: {
|
|
668
|
-
name: 'A',
|
|
669
|
-
actions: { mode: 'actions', allow: { cms_query_items: {} } },
|
|
670
|
-
guardrails: { allowGuest: true, guestToolAllow: ['cms_create_item'] },
|
|
671
|
-
},
|
|
672
|
-
},
|
|
673
|
-
});
|
|
674
|
-
expect(r.ok).toBe(false);
|
|
675
|
-
expect(r.errors.join(' ')).toContain('not in actions.allow');
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
it('a non-empty guestToolAllow requires allowGuest: true', () => {
|
|
679
|
-
const r = validateFeatureConfig('copilot', {
|
|
680
|
-
agents: {
|
|
681
|
-
a: {
|
|
682
|
-
name: 'A',
|
|
683
|
-
actions: { mode: 'actions', allow: { cms_query_items: {} } },
|
|
684
|
-
guardrails: { allowGuest: false, guestToolAllow: ['cms_query_items'] },
|
|
685
|
-
},
|
|
686
|
-
},
|
|
687
|
-
});
|
|
688
|
-
expect(r.ok).toBe(false);
|
|
689
|
-
expect(r.errors.join(' ')).toContain('allowGuest');
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
it('widget.requireAuth=false requires ≥1 agent with allowGuest', () => {
|
|
693
|
-
const bad = validateFeatureConfig('copilot', {
|
|
694
|
-
agents: { a: { name: 'A' } },
|
|
695
|
-
widget: { enabled: true, requireAuth: false, allowedOrigins: ['https://example.com'] },
|
|
696
|
-
});
|
|
697
|
-
expect(bad.ok).toBe(false);
|
|
698
|
-
expect(bad.errors.join(' ')).toContain('allowGuest');
|
|
699
|
-
|
|
700
|
-
const good = validateFeatureConfig('copilot', {
|
|
701
|
-
agents: { a: { name: 'A', guardrails: { allowGuest: true } } },
|
|
702
|
-
widget: { enabled: true, requireAuth: false, allowedOrigins: ['https://example.com'] },
|
|
703
|
-
});
|
|
704
|
-
expect(good.ok).toBe(true);
|
|
705
|
-
});
|
|
706
|
-
|
|
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
|
-
});
|
|
714
|
-
|
|
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,
|
|
719
|
-
agents: {},
|
|
720
|
-
escalation: { enabled: true, handler: 'notifications', notifyTemplate: 'escalation' },
|
|
721
|
-
limits: { consumeCredits: true, tokensPerUserPerDay: 5000 },
|
|
722
|
-
});
|
|
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
|
|
728
|
-
});
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
// fn-online-typed-io: the per-function `signature` (Level-1 typed I/O) is
|
|
732
|
-
// DECLARED schema data inside the functions Type.Record value — Value.Clean
|
|
733
|
-
// must keep it, the size guard must bound it, and the leaf count must not move.
|
|
734
|
-
describe('functions config — signature (Level-1 typed I/O)', () => {
|
|
735
|
-
const entry = (extra: Record<string, unknown> = {}) => ({
|
|
736
|
-
enabled: true,
|
|
737
|
-
functions: {
|
|
738
|
-
hello: { scriptRef: 'fn-t-hello-0123456789ab', ...extra },
|
|
739
|
-
},
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
it('signature survives validateFeatureConfig (Value.Clean keeps declared keys)', () => {
|
|
743
|
-
const sig = { input: { name: 'string' }, output: { ok: 'boolean' } };
|
|
744
|
-
const r = validateFeatureConfig('functions', entry({ signature: sig }));
|
|
745
|
-
expect(r.ok).toBe(true);
|
|
746
|
-
const fns = (r.value as { functions: Record<string, { signature?: unknown }> }).functions;
|
|
747
|
-
expect(fns.hello!.signature).toEqual(sig);
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
it('rejects an oversize signature (manifest bloat guard), naming the path', () => {
|
|
751
|
-
const big = { input: { blob: 'x'.repeat(9_000) } };
|
|
752
|
-
const r = validateFeatureConfig('functions', entry({ signature: big }));
|
|
753
|
-
expect(r.ok).toBe(false);
|
|
754
|
-
expect(r.errors.join(' ')).toContain('/functions/hello/signature');
|
|
755
|
-
});
|
|
756
|
-
|
|
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);
|
|
759
|
-
});
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
describe('cms-rel config: readModels + cdc (cms-relational-depth §3/§4)', () => {
|
|
763
|
-
it('cms schema lands at 15 leaves (relations +3, readModels +1, cdc +1, strictEndUserScope +1) — AT the cap', () => {
|
|
764
|
-
expect(countLeaves(FEATURE_SCHEMAS['cms']!)).toBe(15);
|
|
765
|
-
expect(countLeaves(FEATURE_SCHEMAS['cms']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
it('relations block defaults + range enforcement', () => {
|
|
769
|
-
const r = validateFeatureConfig('cms', {});
|
|
770
|
-
expect(r.ok).toBe(true);
|
|
771
|
-
const rel = (r.value as { relations: Record<string, number> }).relations;
|
|
772
|
-
expect(rel).toEqual({ maxExpandDepth: 1, maxExpandFields: 5, maxComputedFieldsPerCollection: 10 });
|
|
773
|
-
expect(validateFeatureConfig('cms', { relations: { maxExpandDepth: 3 } }).ok).toBe(false);
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
const Z1 = {
|
|
777
|
-
readModels: {
|
|
778
|
-
discover_featured: {
|
|
779
|
-
collection: 'posts', kind: 'rank',
|
|
780
|
-
spec: {
|
|
781
|
-
groupBy: 'channel', metric: { fn: 'sum', field: 'view_count' },
|
|
782
|
-
rank: 'percent_rank', direction: 'desc',
|
|
783
|
-
filter: { $status: 'published', 'channel.visibility': 'public' },
|
|
784
|
-
window: { field: 'created_at', sinceDays: 30 },
|
|
785
|
-
},
|
|
786
|
-
materialize: { cron: '0 3 * * *', to: 'discover_rollup' },
|
|
787
|
-
},
|
|
788
|
-
},
|
|
789
|
-
};
|
|
790
|
-
|
|
791
|
-
it('accepts the Zaawi Z1 declared read-model (rank + join filter + cron)', () => {
|
|
792
|
-
const r = validateFeatureConfig('cms', Z1);
|
|
793
|
-
expect(r.errors ?? []).toEqual([]);
|
|
794
|
-
expect(r.ok).toBe(true);
|
|
795
|
-
// Value.Clean must NOT hollow out the opaque spec (the functions signature idiom)
|
|
796
|
-
const kept = (r.value as { readModels: Record<string, { spec: Record<string, unknown> }> })
|
|
797
|
-
.readModels.discover_featured!.spec;
|
|
798
|
-
expect(kept.groupBy).toBe('channel');
|
|
799
|
-
expect((kept.filter as Record<string, unknown>)['channel.visibility']).toBe('public');
|
|
800
|
-
});
|
|
801
|
-
|
|
802
|
-
it('accepts a Z3-style aggregate read-model', () => {
|
|
803
|
-
const r = validateFeatureConfig('cms', {
|
|
804
|
-
readModels: {
|
|
805
|
-
daily_bookings: {
|
|
806
|
-
collection: 'bookings', kind: 'aggregate',
|
|
807
|
-
spec: {
|
|
808
|
-
aggregates: [{ fn: 'count' }, { fn: 'sum', field: 'amount', as: 'revenue' }],
|
|
809
|
-
groupBy: ['space'], window: { field: 'created_at', sinceDays: 1 },
|
|
810
|
-
},
|
|
811
|
-
materialize: { cron: '0 2 * * *', to: 'analytics_daily' },
|
|
812
|
-
},
|
|
813
|
-
},
|
|
814
|
-
});
|
|
815
|
-
expect(r.errors ?? []).toEqual([]);
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
it('rejects fn/rank outside the allow-lists at CONFIG-WRITE time', () => {
|
|
819
|
-
const bad = (spec: object, kind = 'aggregate') => validateFeatureConfig('cms', {
|
|
820
|
-
readModels: { m: { collection: 'c', kind, spec, materialize: { cron: '* * * * *', to: 't' } } },
|
|
821
|
-
});
|
|
822
|
-
expect(bad({ aggregates: [{ fn: 'array_agg', field: 'x' }] }).ok).toBe(false);
|
|
823
|
-
expect(bad({ groupBy: 'x', rank: 'ntile' }, 'rank').ok).toBe(false);
|
|
824
|
-
expect(bad({ groupBy: 'x', rank: 'rank() OVER (ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)' }, 'rank').ok).toBe(false);
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
it('rejects structural violations: arity, window, spec size, names, cron, limit smuggling', () => {
|
|
828
|
-
const rm = (over: object) => validateFeatureConfig('cms', {
|
|
829
|
-
readModels: {
|
|
830
|
-
m: {
|
|
831
|
-
collection: 'c', kind: 'aggregate', spec: { aggregates: [{ fn: 'count' }] },
|
|
832
|
-
materialize: { cron: '0 2 * * *', to: 't' }, ...over,
|
|
833
|
-
},
|
|
834
|
-
},
|
|
835
|
-
});
|
|
836
|
-
expect(rm({ spec: { aggregates: [{ fn: 'count' }], groupBy: ['a', 'b', 'c'] } }).ok).toBe(false);
|
|
837
|
-
expect(rm({ spec: { aggregates: [{ fn: 'count' }], window: { field: 'x', sinceDays: 999 } } }).ok).toBe(false);
|
|
838
|
-
expect(rm({ spec: { aggregates: [{ fn: 'count' }], limit: 10 } }).ok).toBe(false);
|
|
839
|
-
expect(rm({ spec: { aggregates: [{ fn: 'count' }], filter: { 'a.b.c': 1 } } }).ok).toBe(false);
|
|
840
|
-
expect(rm({ spec: { aggregates: [{ fn: 'count' }], filter: { 'a.x': 1, 'b.y': 2 } } }).ok).toBe(false);
|
|
841
|
-
expect(rm({ materialize: { cron: 'not-a-cron', to: 't' } }).ok).toBe(false);
|
|
842
|
-
expect(rm({ materialize: { cron: '0 2 * * *', to: 'Bad Slug!' } }).ok).toBe(false);
|
|
843
|
-
// an over-large serialized spec
|
|
844
|
-
expect(rm({ spec: { aggregates: [{ fn: 'count' }], filter: { note: 'x'.repeat(5000) } } }).ok).toBe(false);
|
|
845
|
-
// bad read-model name
|
|
846
|
-
const r = validateFeatureConfig('cms', {
|
|
847
|
-
readModels: { 'Bad Name': { collection: 'c', kind: 'aggregate', spec: { aggregates: [{ fn: 'count' }] } } },
|
|
848
|
-
});
|
|
849
|
-
expect(r.ok).toBe(false);
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
it('caps the bag at 20 read-models', () => {
|
|
853
|
-
const bag: Record<string, unknown> = {};
|
|
854
|
-
for (let i = 0; i < 21; i++) {
|
|
855
|
-
bag[`m${i}`] = { collection: 'c', kind: 'aggregate', spec: { aggregates: [{ fn: 'count' }] } };
|
|
856
|
-
}
|
|
857
|
-
expect(validateFeatureConfig('cms', { readModels: bag }).ok).toBe(false);
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
it('cdc: accepts a rule bag; rejects bad channel/events/payload and >25 rules', () => {
|
|
861
|
-
const ok = validateFeatureConfig('cms', {
|
|
862
|
-
cdc: {
|
|
863
|
-
posts_live: { collection: 'posts', channel: 'posts:live', events: ['created', 'updated'], payload: 'ids' },
|
|
864
|
-
},
|
|
865
|
-
});
|
|
866
|
-
expect(ok.errors ?? []).toEqual([]);
|
|
867
|
-
const bad = (rule: object) => validateFeatureConfig('cms', { cdc: { r: rule } });
|
|
868
|
-
expect(bad({ collection: 'posts', channel: 'has spaces!' }).ok).toBe(false);
|
|
869
|
-
expect(bad({ collection: 'posts', channel: 'c', events: ['archived'] }).ok).toBe(false);
|
|
870
|
-
expect(bad({ collection: 'posts', channel: 'c', payload: 'everything' }).ok).toBe(false);
|
|
871
|
-
expect(bad({ channel: 'c' }).ok).toBe(false);
|
|
872
|
-
const bag: Record<string, unknown> = {};
|
|
873
|
-
for (let i = 0; i < 26; i++) bag[`r${i}`] = { collection: 'c', channel: `ch${i}` };
|
|
874
|
-
expect(validateFeatureConfig('cms', { cdc: bag }).ok).toBe(false);
|
|
875
|
-
});
|
|
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
|
-
});
|