|
32 | 32 | // to see pre-parse evidence". |
33 | 33 |
|
34 | 34 | import { describe, expect, it, vi } from 'vitest'; |
35 | | -import { defineStack, normalizeStackInput } from '@objectstack/spec'; |
| 35 | +import { |
| 36 | + ObjectStackSchema, |
| 37 | + applyConversionsToStoredItem, |
| 38 | + defineStack, |
| 39 | + normalizeStackInput, |
| 40 | +} from '@objectstack/spec'; |
| 41 | +import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; |
36 | 42 |
|
37 | 43 | import { validateListViewMode } from './validate-list-view-mode.js'; |
38 | 44 | import { validateViewContainers } from './validate-view-containers.js'; |
39 | 45 | import { validateVisibilityPredicates } from './validate-visibility-predicates.js'; |
40 | 46 | import { runAuthoringRules } from './authoring-rules.js'; |
| 47 | +import { runRuntimeAuthoringRules } from './runtime-gate.js'; |
| 48 | +import { SECURITY_OWD_ALIAS } from './validate-security-posture.js'; |
41 | 49 |
|
42 | 50 | type AnyRec = Record<string, unknown>; |
43 | 51 |
|
@@ -374,3 +382,108 @@ describe('what `normalized` DOES buy: findings survive a schema error that stops |
374 | 382 | expect(rules.has('view-container-shape')).toBe(true); |
375 | 383 | }); |
376 | 384 | }); |
| 385 | + |
| 386 | +// ─────────────────────────────────────────────────────────────────────────── |
| 387 | +// #16109 — `security-owd-alias` reaches the rule ONLY through the unparsed doors. |
| 388 | +// |
| 389 | +// `ObjectSchema.sharingModel` / `externalSharingModel` are closed enums |
| 390 | +// (ADR-0090 D4 / D11): every alias the rule names is refused with |
| 391 | +// `invalid_value` on any door that parses before the registry runs, so on a |
| 392 | +// `defineStack`-authored app the rule is dead by construction — the card's own |
| 393 | +// hotcrm measurement. It is NOT dead: `os lint` never parses, `loadConfig` |
| 394 | +// hands a raw object-literal config on as authored, and the ADR-0087 stored-row |
| 395 | +// conversion for these aliases is `retiredFromLoadPath`, so the alias survives |
| 396 | +// `normalizeStackInput` and the rule is the only diagnostic that door gets. |
| 397 | +// Each leg below is paired with the parsed-door control taken in the same run; |
| 398 | +// the module docblock's "## Intake" table in `validate-security-posture.ts` |
| 399 | +// is the prose form of these pins. |
| 400 | +describe('security-owd-alias reaches the rule only through the unparsed doors (#16109)', () => { |
| 401 | + const owdObject = (sharingModel: string) => ({ |
| 402 | + name: 'tier_owd', |
| 403 | + label: 'OWD', |
| 404 | + sharingModel, |
| 405 | + fields: { title: { type: 'text', label: 'Title' } }, |
| 406 | + }); |
| 407 | + const rawStack = (sharingModel: string) => ({ manifest, objects: [owdObject(sharingModel)] }); |
| 408 | + const aliasFindings = (findings: readonly { rule: string; path: string }[]) => |
| 409 | + findings.filter((f) => f.rule === SECURITY_OWD_ALIAS).map((f) => f.path); |
| 410 | + /** Every key of the rule's `OWD_ALIAS_FIX` map. */ |
| 411 | + const ALIASES = ['read', 'read_write', 'full', 'public'] as const; |
| 412 | + |
| 413 | + it.each(ALIASES)('CONTROL — defineStack (strict default) refuses %s at load, before any rule runs', (alias) => { |
| 414 | + const { error } = quietly(() => defineStack(rawStack(alias) as never)); |
| 415 | + expect(error).toBeDefined(); |
| 416 | + expect(error!.message).toContain('objects.0.sharingModel'); |
| 417 | + }); |
| 418 | + |
| 419 | + it.each(ALIASES)('CONTROL — the os validate / os compile schema step refuses %s on a raw config', (alias) => { |
| 420 | + const parsed = ObjectStackSchema.safeParse(normalizeStackInput(rawStack(alias))); |
| 421 | + expect(parsed.success).toBe(false); |
| 422 | + const issue = parsed.success ? undefined : parsed.error.issues.find((i) => i.path.join('.') === 'objects.0.sharingModel'); |
| 423 | + expect(issue?.code).toBe('invalid_value'); |
| 424 | + }); |
| 425 | + |
| 426 | + it.each(ALIASES)('INTAKE — os lint on a raw object-literal config hands %s to the rule intact, and the rule fires', (alias) => { |
| 427 | + // Exactly the call `lint.ts` makes: `loadConfig` (no parse) → `normalizeStackInput` |
| 428 | + // → `runAuthoringRules('lint', { normalized })`. No conversion notice fires: |
| 429 | + // `owd-legacy-read-aliases` is retired from the load path, and `full` / |
| 430 | + // `public` never had one. |
| 431 | + const notices: string[] = []; |
| 432 | + const normalized = normalizeStackInput(rawStack(alias), { |
| 433 | + onConversionNotice: (n) => notices.push(n.conversionId), |
| 434 | + }) as AnyRec; |
| 435 | + expect((normalized.objects as AnyRec[])[0].sharingModel).toBe(alias); |
| 436 | + expect(notices).toEqual([]); |
| 437 | + expect(aliasFindings(runAuthoringRules('lint', { normalized }))).toEqual(['objects[0].sharingModel']); |
| 438 | + }); |
| 439 | + |
| 440 | + it('INTAKE — defineStack(x, { strict: false }) skips the parse, so the alias reaches os lint too', () => { |
| 441 | + const loose = quietly(() => defineStack(rawStack('read_write') as never, { strict: false })).value as AnyRec; |
| 442 | + expect((loose.objects as AnyRec[])[0].sharingModel).toBe('read_write'); |
| 443 | + expect(aliasFindings(runAuthoringRules('lint', { normalized: normalizeStackInput(loose) as AnyRec }))).toEqual([ |
| 444 | + 'objects[0].sharingModel', |
| 445 | + ]); |
| 446 | + }); |
| 447 | + |
| 448 | + it('CONTROL — the rule is silent on a canonical value through the same unparsed door', () => { |
| 449 | + const normalized = normalizeStackInput(rawStack('public_read')) as AnyRec; |
| 450 | + expect(aliasFindings(runAuthoringRules('lint', { normalized }))).toEqual([]); |
| 451 | + }); |
| 452 | + |
| 453 | + it.each(ALIASES)('CONTROL — the runtime publish door refuses %s with the object schema before the gate runs', (alias) => { |
| 454 | + // `saveMetaItem` runs `getMetadataTypeSchema('object').safeParse` and 422s |
| 455 | + // BEFORE `runRuntimeAuthoringRules`; the gate never sees this item. |
| 456 | + const schema = getMetadataTypeSchema('object'); |
| 457 | + expect(schema).toBeDefined(); |
| 458 | + const parsed = schema!.safeParse(owdObject(alias)); |
| 459 | + expect(parsed.success).toBe(false); |
| 460 | + expect(parsed.success ? undefined : parsed.error.issues.find((i) => i.path.join('.') === 'sharingModel')?.code).toBe('invalid_value'); |
| 461 | + }); |
| 462 | + |
| 463 | + it('INTAKE — runRuntimeAuthoringRules called directly with an unparsed item fires (the exported API is a door)', () => { |
| 464 | + const result = runRuntimeAuthoringRules({ |
| 465 | + type: 'object', |
| 466 | + item: owdObject('full'), |
| 467 | + context: { objects: [], permissions: [], books: [], datasets: [], pages: [] }, |
| 468 | + }); |
| 469 | + expect(aliasFindings(result.errors)).toEqual(['objects.tier_owd.sharingModel']); |
| 470 | + }); |
| 471 | + |
| 472 | + it('CONTROL — a pre-D4 stored sibling does not surface: read/read_write fold on rehydration, and the gate diff cancels the rest', () => { |
| 473 | + // The stored-row chain replays retired conversions, so `read` / `read_write` |
| 474 | + // come back canonical… |
| 475 | + expect((applyConversionsToStoredItem('object', owdObject('read')) as AnyRec).sharingModel).toBe('public_read'); |
| 476 | + expect((applyConversionsToStoredItem('object', owdObject('read_write')) as AnyRec).sharingModel).toBe('public_read_write'); |
| 477 | + // …`full` / `public` have no conversion and come back as authored… |
| 478 | + const storedFull = applyConversionsToStoredItem('object', owdObject('full')) as AnyRec; |
| 479 | + expect(storedFull.sharingModel).toBe('full'); |
| 480 | + // …and even so, a sibling in the gate's universe produces the finding in |
| 481 | + // BOTH the baseline and the candidate pass, so it never leaves the gate. |
| 482 | + const result = runRuntimeAuthoringRules({ |
| 483 | + type: 'object', |
| 484 | + item: { ...owdObject('private'), name: 'tier_other' }, |
| 485 | + context: { objects: [storedFull], permissions: [], books: [], datasets: [], pages: [] }, |
| 486 | + }); |
| 487 | + expect(aliasFindings(result.errors)).toEqual([]); |
| 488 | + }); |
| 489 | +}); |
0 commit comments