@@ -413,6 +413,188 @@ describe('SettingsService — save-time validation (required/visible/pattern)',
413413 } ) ;
414414} ) ;
415415
416+ /**
417+ * #5131 — a manifest's `options` table is enforced at SAVE time.
418+ *
419+ * Until this suite existed the enumeration was a front-end convention: the
420+ * console dropdown only ever emitted legal values, so an admin going through
421+ * the UI could not produce a bad one — but `PUT /api/settings/:ns` is an
422+ * authorizable public surface, and a script, a migration or AI-authored
423+ * bootstrap code could write any string at all into a `select` and have it
424+ * stored, read back, and improvised over by each consumer in turn.
425+ *
426+ * The load-bearing case is `mail.provider`: #5094/#5133 retired `sendgrid`
427+ * and `ses` from the option table because this server cannot deliver through
428+ * them, and that manifest-side tightening had no matching gate on the API
429+ * side — the very values just retired could be written straight back in.
430+ */
431+ describe ( 'SettingsService — save-time validation (declared options are enforced)' , ( ) => {
432+ const mailService = ( ) => {
433+ const svc = new SettingsService ( { env : { } } ) ;
434+ svc . registerManifest ( mailSettingsManifest ) ;
435+ return svc ;
436+ } ;
437+
438+ it ( 'refuses a provider outside the declared table, naming the allowed set' , async ( ) => {
439+ const svc = mailService ( ) ;
440+ // `sendgrid` left the table in #5094; before this gate it could be written
441+ // back the same afternoon it was retired.
442+ await expect (
443+ svc . setMany ( 'mail' , { provider : 'sendgrid' , from_email : 'a@b.com' } ) ,
444+ ) . rejects . toMatchObject ( {
445+ code : 'SETTINGS_VALIDATION' ,
446+ fields : [
447+ {
448+ field : 'provider' ,
449+ code : 'invalid_option' ,
450+ label : 'Provider' ,
451+ // The allowed set travels as a discrete constraint (ADR-0114), so a
452+ // client branches on the machine value instead of parsing our prose.
453+ constraint : { allowed : 'smtp, resend, postmark, log' } ,
454+ value : 'sendgrid' ,
455+ } ,
456+ ] ,
457+ } ) ;
458+ // Atomic: the rejected batch persisted nothing, not even the legal key.
459+ expect ( ( await svc . get ( 'mail' , 'provider' ) ) . source ) . toBe ( 'default' ) ;
460+ expect ( ( await svc . get ( 'mail' , 'from_email' ) ) . value ) . toBeNull ( ) ;
461+ } ) ;
462+
463+ it ( 'accepts every value the table does declare' , async ( ) => {
464+ for ( const [ provider , extra ] of [
465+ [ 'smtp' , { smtp_host : 'smtp.example.com' } ] ,
466+ [ 'resend' , { api_key : 're-key' } ] ,
467+ [ 'postmark' , { api_key : 'pm-key' } ] ,
468+ [ 'log' , { } ] ,
469+ ] as const ) {
470+ const svc = mailService ( ) ;
471+ await expect (
472+ svc . setMany ( 'mail' , { provider, ...extra , from_email : 'a@b.com' } ) ,
473+ ) . resolves . toBeDefined ( ) ;
474+ expect ( ( await svc . get ( 'mail' , 'provider' ) ) . value ) . toBe ( provider ) ;
475+ }
476+ } ) ;
477+
478+ it ( 'checks the option table only when the patch TOUCHES the key' , async ( ) => {
479+ // A workspace that saved `sendgrid` while the option existed still carries
480+ // it. Simulated exactly as it happened: write under the OLD table, then
481+ // re-register the narrowed manifest (#5094) over the same namespace.
482+ const svc = new SettingsService ( { env : { } } ) ;
483+ svc . registerManifest ( {
484+ ...mailSettingsManifest ,
485+ specifiers : mailSettingsManifest . specifiers . map ( ( s : any ) =>
486+ s . key === 'provider'
487+ ? { ...s , options : [ ...s . options , { value : 'sendgrid' , label : 'SendGrid' } ] }
488+ : s ,
489+ ) ,
490+ } as any ) ;
491+ await svc . setMany ( 'mail' , { provider : 'sendgrid' , api_key : 'sg-key' , from_email : 'a@b.com' } ) ;
492+ svc . registerManifest ( mailSettingsManifest ) ;
493+
494+ // The stale value is still there …
495+ expect ( ( await svc . get ( 'mail' , 'provider' ) ) . value ) . toBe ( 'sendgrid' ) ;
496+ // … and it does NOT lock the workspace out of editing anything else. A
497+ // patch that never mentions `provider` is not rejected on its account —
498+ // the opposite rule would make the settings page unusable for every
499+ // workspace carrying historical drift, which is worse than the gap.
500+ await expect ( svc . setMany ( 'mail' , { from_name : 'Acme Ops' } ) ) . resolves . toBeDefined ( ) ;
501+ expect ( ( await svc . get ( 'mail' , 'from_name' ) ) . value ) . toBe ( 'Acme Ops' ) ;
502+ // Only re-writing the key itself is refused.
503+ await expect ( svc . setMany ( 'mail' , { provider : 'sendgrid' } ) ) . rejects . toMatchObject ( {
504+ code : 'SETTINGS_VALIDATION' ,
505+ fields : [ { field : 'provider' , code : 'invalid_option' } ] ,
506+ } ) ;
507+ // And a reset still clears it — an all-null patch is never blocked.
508+ await expect ( svc . resetNamespace ( 'mail' ) ) . resolves . toBeGreaterThan ( 0 ) ;
509+ } ) ;
510+
511+ it ( 'leaves the value alone when the specifier declares no option table' , async ( ) => {
512+ // `registerManifest` takes manifests as given (no Zod pass), so a
513+ // hand-built select without `options` reaches the validator. It cannot say
514+ // what is legal, so it stays lenient rather than rejecting every write.
515+ const svc = new SettingsService ( { env : { } } ) ;
516+ svc . registerManifest ( {
517+ namespace : 'freeform' ,
518+ label : 'Freeform' ,
519+ specifiers : [ { type : 'select' , key : 'mode' , label : 'Mode' } ] ,
520+ } as any ) ;
521+ await expect ( svc . setMany ( 'freeform' , { mode : 'whatever' } ) ) . resolves . toBeDefined ( ) ;
522+ } ) ;
523+
524+ it ( 'enforces radio and multiselect from the same table, element-wise' , async ( ) => {
525+ // All three types are covered because the SPEC requires an `options` table
526+ // on all three; `radio`/`multiselect` have no producer manifest today and
527+ // would otherwise be a hole the first one to author them falls into.
528+ const svc = new SettingsService ( { env : { } } ) ;
529+ svc . registerManifest ( {
530+ namespace : 'shapes' ,
531+ label : 'Shapes' ,
532+ specifiers : [
533+ { type : 'radio' , key : 'tier' , label : 'Tier' ,
534+ options : [ { value : 'free' , label : 'Free' } , { value : 'pro' , label : 'Pro' } ] } ,
535+ { type : 'multiselect' , key : 'channels' , label : 'Channels' ,
536+ options : [ { value : 'email' , label : 'Email' } , { value : 'sms' , label : 'SMS' } ] } ,
537+ ] ,
538+ } as any ) ;
539+
540+ await expect ( svc . setMany ( 'shapes' , { tier : 'enterprise' } ) ) . rejects . toMatchObject ( {
541+ fields : [ { field : 'tier' , code : 'invalid_option' , constraint : { allowed : 'free, pro' } } ] ,
542+ } ) ;
543+ await expect ( svc . setMany ( 'shapes' , { tier : 'pro' } ) ) . resolves . toBeDefined ( ) ;
544+
545+ // Every element is checked, and the rejected one is the one reported.
546+ await expect (
547+ svc . setMany ( 'shapes' , { channels : [ 'email' , 'carrier-pigeon' ] } ) ,
548+ ) . rejects . toMatchObject ( {
549+ fields : [ { field : 'channels' , code : 'invalid_option' , value : 'carrier-pigeon' } ] ,
550+ } ) ;
551+ await expect ( svc . setMany ( 'shapes' , { channels : [ 'email' , 'sms' ] } ) ) . resolves . toBeDefined ( ) ;
552+ await expect ( svc . setMany ( 'shapes' , { channels : [ ] } ) ) . resolves . toBeDefined ( ) ;
553+ } ) ;
554+
555+ it ( 'matches option values by string form, so a number option survives JSON' , async ( ) => {
556+ // A stored value has been through JSON and, over REST, a form post: an
557+ // option declared `value: 30` legitimately reads back as '30'. Rejecting
558+ // that would enforce the transport rather than the enumeration.
559+ const svc = new SettingsService ( { env : { } } ) ;
560+ svc . registerManifest ( {
561+ namespace : 'retention' ,
562+ label : 'Retention' ,
563+ specifiers : [
564+ { type : 'select' , key : 'days' , label : 'Days' ,
565+ options : [ { value : 7 , label : '7' } , { value : 30 , label : '30' } ] } ,
566+ { type : 'select' , key : 'archive' , label : 'Archive' ,
567+ options : [ { value : true , label : 'On' } , { value : false , label : 'Off' } ] } ,
568+ ] ,
569+ } as any ) ;
570+ await expect ( svc . setMany ( 'retention' , { days : 30 } ) ) . resolves . toBeDefined ( ) ;
571+ await expect ( svc . setMany ( 'retention' , { days : '30' } ) ) . resolves . toBeDefined ( ) ;
572+ await expect ( svc . setMany ( 'retention' , { archive : false } ) ) . resolves . toBeDefined ( ) ;
573+ await expect ( svc . setMany ( 'retention' , { days : 45 } ) ) . rejects . toMatchObject ( {
574+ fields : [ { field : 'days' , code : 'invalid_option' , constraint : { allowed : '7, 30' } } ] ,
575+ } ) ;
576+ } ) ;
577+
578+ it ( 'never echoes the rejected value for an encrypted specifier' , async ( ) => {
579+ // `encrypted` is authorable on any specifier, and this message lands in
580+ // logs — so the offending value is named only where it is safe to name.
581+ const svc = new SettingsService ( { env : { } } ) ;
582+ svc . registerManifest ( {
583+ namespace : 'vault' ,
584+ label : 'Vault' ,
585+ specifiers : [
586+ { type : 'select' , key : 'key_ref' , label : 'Key' , encrypted : true ,
587+ options : [ { value : 'primary' , label : 'Primary' } ] } ,
588+ ] ,
589+ } as any ) ;
590+ const err = await svc . setMany ( 'vault' , { key_ref : 's3cr3t-handle' } ) . catch ( ( e ) => e ) ;
591+ expect ( err . code ) . toBe ( 'SETTINGS_VALIDATION' ) ;
592+ expect ( err . fields [ 0 ] ) . toMatchObject ( { field : 'key_ref' , code : 'invalid_option' } ) ;
593+ expect ( err . fields [ 0 ] . value ) . toBeUndefined ( ) ;
594+ expect ( err . message ) . not . toContain ( 's3cr3t-handle' ) ;
595+ } ) ;
596+ } ) ;
597+
416598describe ( 'SettingsService — user-scoped values' , ( ) => {
417599 it ( 'isolates writes by ctx.userId' , async ( ) => {
418600 const svc = new SettingsService ( { env : { } } ) ;
0 commit comments