@openwop/openwop-conformance 1.25.0 → 1.28.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.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Localized content surface — durable authored content (pages → sections) that
3
+ * reuses the Stable i18n annex's Accept-Language/Content-Language negotiation
4
+ * (RFC 0103; `spec/v1/localized-content.md`). Public tests for the protocol-tier
5
+ * SECURITY invariants `content-published-cache-no-draft`,
6
+ * `content-response-tenant-scoped`, and `content-no-cross-tenant-enumeration`.
7
+ *
8
+ * Two layers:
9
+ *
10
+ * A. Always-on, server-free legs — the `content` capability block, the four
11
+ * content schemas (section / page / language-settings / page-response),
12
+ * the §A capability-coherence constraints, and the §C per-section field
13
+ * merge reference algorithm (`resolveSection`, exact → family → base,
14
+ * shallow overlay) shared verbatim with hosts.
15
+ *
16
+ * B. Capability-gated behavioral legs — on a host advertising
17
+ * `capabilities.content` that exposes `GET /v1/content/pages/{slug}`:
18
+ * malformed Accept-Language succeeds with the base locale, Content-Language
19
+ * reflects the locale used, published-only delivery, tenant isolation, and
20
+ * no cross-tenant enumeration. Unadvertised hosts skip via the gate;
21
+ * hosts without a live target soft-skip (no OPENWOP_BASE_URL).
22
+ *
23
+ * @see spec/v1/localized-content.md §A–§F
24
+ * @see spec/v1/i18n.md §"Accept-Language request header", §"Fallback rules"
25
+ * @see SECURITY/invariants.yaml id: content-published-cache-no-draft, content-response-tenant-scoped, content-no-cross-tenant-enumeration
26
+ * @see RFCS/0103-localized-content-surface.md
27
+ */
28
+
29
+ import { describe, it, expect } from 'vitest';
30
+ import { readFileSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+ import Ajv2020 from 'ajv/dist/2020.js';
33
+ import addFormats from 'ajv-formats';
34
+ import { SCHEMAS_DIR } from '../lib/paths.js';
35
+ import { driver } from '../lib/driver.js';
36
+ import { behaviorGate } from '../lib/behavior-gate.js';
37
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
38
+
39
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
40
+ function loadSchema(name: string): Record<string, unknown> {
41
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
42
+ }
43
+
44
+ interface ContentCap {
45
+ supported?: boolean;
46
+ baseLocale?: string;
47
+ supportedLocales?: string[];
48
+ }
49
+ interface I18nCap {
50
+ supported?: boolean;
51
+ defaultLocale?: string;
52
+ supportedLocales?: string[];
53
+ }
54
+
55
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
56
+
57
+ // ── §C reference merge — shared verbatim with conforming hosts ──────────────
58
+ type Section = {
59
+ data: Record<string, unknown>;
60
+ localizations: Record<string, Record<string, unknown>>;
61
+ };
62
+ function resolveSection(section: Section, negotiatedLocale: string, baseLocale: string): Record<string, unknown> {
63
+ const loc = section.localizations ?? {};
64
+ if (negotiatedLocale === baseLocale || Object.keys(loc).length === 0) return section.data;
65
+ if (loc[negotiatedLocale]) return { ...section.data, ...loc[negotiatedLocale] };
66
+ if (negotiatedLocale.includes('-')) {
67
+ const lang = negotiatedLocale.split('-')[0];
68
+ if (loc[lang]) return { ...section.data, ...loc[lang] };
69
+ }
70
+ return section.data;
71
+ }
72
+
73
+ // ── §A capability-coherence predicate ──────────────────────────────────────
74
+ function contentCoherent(content: ContentCap, i18n: I18nCap): boolean {
75
+ if (content.supported !== true) return true; // absent/false ⇒ nothing to check
76
+ if (i18n.supported !== true) return false; // (1) requires i18n
77
+ if (content.baseLocale !== i18n.defaultLocale) return false; // (2)
78
+ const i18nSet = new Set(i18n.supportedLocales ?? []);
79
+ const resolvable = [content.baseLocale!, ...(content.supportedLocales ?? [])];
80
+ if (!resolvable.every((l) => i18nSet.has(l))) return false; // (3) subset
81
+ if ((content.supportedLocales ?? []).includes(content.baseLocale!)) return false; // (4) base ∉ supported
82
+ return true;
83
+ }
84
+
85
+ // ════════════════════════════════════════════════════════════════════════════
86
+ // A. Server-free legs
87
+ // ════════════════════════════════════════════════════════════════════════════
88
+
89
+ describe('localized-content: capability advertisement shape (localized-content.md §A, server-free)', () => {
90
+ it('capabilities schema declares content with its required fields', () => {
91
+ const caps = loadSchema('capabilities.schema.json');
92
+ const content = (caps.properties as Record<string, { required?: string[]; properties?: Record<string, unknown> }>).content;
93
+ expect(content, why('capabilities.schema.json §content', 'the content block MUST be declared')).toBeDefined();
94
+ expect(content?.required, why('localized-content.md §A', 'supported + baseLocale + supportedLocales MUST be required')).toEqual(
95
+ expect.arrayContaining(['supported', 'baseLocale', 'supportedLocales']),
96
+ );
97
+ for (const f of ['supported', 'baseLocale', 'supportedLocales']) {
98
+ expect(content?.properties?.[f], why('localized-content.md §A', `content.${f} MUST be declared`)).toBeDefined();
99
+ }
100
+ });
101
+ });
102
+
103
+ describe('localized-content: schema shapes (localized-content.md §B, server-free)', () => {
104
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
105
+ addFormats(ajv);
106
+ const section = ajv.compile(loadSchema('localized-content-section.schema.json'));
107
+ const page = ajv.compile(loadSchema('localized-content-page.schema.json'));
108
+ const settings = ajv.compile(loadSchema('localized-content-language-settings.schema.json'));
109
+ const response = ajv.compile(loadSchema('localized-content-page-response.schema.json'));
110
+
111
+ const goodSection = {
112
+ sectionId: 'hero',
113
+ sectionType: 'hero',
114
+ data: { heading: 'Welcome', cta: 'Get started' },
115
+ localizations: { es: { heading: 'Bienvenido', cta: 'Empezar' }, 'pt-BR': { heading: 'Bem-vindo' } },
116
+ status: 'published',
117
+ enabled: true,
118
+ order: 0,
119
+ };
120
+
121
+ it('a conforming section validates', () => {
122
+ expect(section(goodSection), why('RFC 0103 §B', `a conforming section MUST validate. Errors: ${JSON.stringify(section.errors)}`)).toBe(true);
123
+ });
124
+ it('a localizations key with wrong case/underscore is rejected', () => {
125
+ expect(section({ ...goodSection, localizations: { EN: { heading: 'x' } } }), why('RFC 0103 §B', 'a non-BCP-47-subset key MUST be rejected')).toBe(false);
126
+ expect(section({ ...goodSection, localizations: { en_US: { heading: 'x' } } }), why('RFC 0103 §B', 'an underscore locale key MUST be rejected')).toBe(false);
127
+ });
128
+ it('a section missing a required field is rejected', () => {
129
+ const { status: _omit, ...noStatus } = goodSection;
130
+ expect(section(noStatus), why('RFC 0103 §B', 'status is REQUIRED')).toBe(false);
131
+ });
132
+ it('a status outside the enum is rejected', () => {
133
+ expect(section({ ...goodSection, status: 'archived' }), why('RFC 0103 §B', 'status MUST be draft|published')).toBe(false);
134
+ });
135
+
136
+ it('a conforming page validates; a bad slug is rejected', () => {
137
+ const goodPage = { pageId: 'home', slug: 'home', name: 'Home', status: 'published', sectionOrder: ['hero'] };
138
+ expect(page(goodPage), why('RFC 0103 §B', `a conforming page MUST validate. Errors: ${JSON.stringify(page.errors)}`)).toBe(true);
139
+ expect(page({ ...goodPage, slug: 'Home Page' }), why('RFC 0103 §B', 'slug MUST match ^[a-z][a-z0-9-]*$')).toBe(false);
140
+ });
141
+
142
+ it('a conforming language-settings validates', () => {
143
+ const good = { baseLocale: 'en', supportedLocales: ['es', 'pt-BR', 'fr'], autoTranslateOnPublish: false };
144
+ expect(settings(good), why('RFC 0103 §B', `settings MUST validate. Errors: ${JSON.stringify(settings.errors)}`)).toBe(true);
145
+ });
146
+
147
+ it('a conforming page-response validates', () => {
148
+ const good = {
149
+ version: '1',
150
+ generatedAt: '2026-06-17T00:00:00Z',
151
+ locale: 'pt-BR',
152
+ slug: 'home',
153
+ page: { pageId: 'home', slug: 'home', name: 'Home' },
154
+ sections: [{ sectionId: 'hero', sectionType: 'hero', data: { heading: 'Bem-vindo', cta: 'Get started' } }],
155
+ };
156
+ expect(response(good), why('RFC 0103 §D', `a resolved response MUST validate. Errors: ${JSON.stringify(response.errors)}`)).toBe(true);
157
+ });
158
+ });
159
+
160
+ describe('localized-content: per-section field merge (localized-content.md §C, server-free)', () => {
161
+ const section: Section = {
162
+ data: { heading: 'Welcome', cta: 'Get started' },
163
+ localizations: { es: { heading: 'Bienvenido', cta: 'Empezar' }, 'pt-BR': { heading: 'Bem-vindo' } },
164
+ };
165
+
166
+ it('exact-locale hit overrides matching fields', () => {
167
+ expect(resolveSection(section, 'es', 'en'), why('RFC 0103 §C', 'exact hit MUST overlay locale fields onto data')).toEqual({
168
+ heading: 'Bienvenido',
169
+ cta: 'Empezar',
170
+ });
171
+ });
172
+ it('partial translation falls through to base for missing fields', () => {
173
+ expect(resolveSection(section, 'pt-BR', 'en'), why('RFC 0103 §C', 'missing locale fields MUST fall through to data')).toEqual({
174
+ heading: 'Bem-vindo',
175
+ cta: 'Get started',
176
+ });
177
+ });
178
+ it('language-family fallback applies when exact tag is absent', () => {
179
+ const s: Section = { data: { h: 'Hi' }, localizations: { pt: { h: 'Oi' } } };
180
+ expect(resolveSection(s, 'pt-BR', 'en'), why('RFC 0103 §C', 'pt-BR MUST fall back to the pt family override')).toEqual({ h: 'Oi' });
181
+ });
182
+ it('unsupported/base locale returns base data unchanged', () => {
183
+ expect(resolveSection(section, 'de', 'en'), why('RFC 0103 §C', 'no match MUST return base data')).toEqual(section.data);
184
+ expect(resolveSection(section, 'en', 'en'), why('RFC 0103 §C', 'base locale MUST return base data')).toEqual(section.data);
185
+ });
186
+ });
187
+
188
+ describe('localized-content: §A capability coherence predicate (server-free)', () => {
189
+ const i18n: I18nCap = { supported: true, defaultLocale: 'en', supportedLocales: ['en', 'es', 'pt-BR', 'fr'] };
190
+ it('a coherent advertisement passes', () => {
191
+ expect(contentCoherent({ supported: true, baseLocale: 'en', supportedLocales: ['es', 'pt-BR', 'fr'] }, i18n), why('RFC 0103 §A', 'a coherent content block MUST pass')).toBe(true);
192
+ });
193
+ it('content without i18n is incoherent', () => {
194
+ expect(contentCoherent({ supported: true, baseLocale: 'en', supportedLocales: ['es'] }, { supported: false }), why('RFC 0103 §A.1', 'content requires i18n.supported')).toBe(false);
195
+ });
196
+ it('baseLocale != i18n.defaultLocale is incoherent', () => {
197
+ expect(contentCoherent({ supported: true, baseLocale: 'es', supportedLocales: ['fr'] }, i18n), why('RFC 0103 §A.2', 'baseLocale MUST equal i18n.defaultLocale')).toBe(false);
198
+ });
199
+ it('a supportedLocales not ⊆ i18n.supportedLocales is incoherent', () => {
200
+ expect(contentCoherent({ supported: true, baseLocale: 'en', supportedLocales: ['de'] }, i18n), why('RFC 0103 §A.3', 'content locales MUST be a subset of i18n locales')).toBe(false);
201
+ });
202
+ it('baseLocale appearing in supportedLocales is incoherent', () => {
203
+ expect(contentCoherent({ supported: true, baseLocale: 'en', supportedLocales: ['en', 'es'] }, i18n), why('RFC 0103 §A.4', 'baseLocale MUST NOT appear in supportedLocales')).toBe(false);
204
+ });
205
+ });
206
+
207
+ // ════════════════════════════════════════════════════════════════════════════
208
+ // B. Capability-gated behavioral legs (live host)
209
+ // ════════════════════════════════════════════════════════════════════════════
210
+
211
+ describe.skipIf(HTTP_SKIP)('localized-content: live advertisement coherence (localized-content.md §A)', () => {
212
+ it('advertised content block is coherent with the advertised i18n block', async () => {
213
+ const content = await readCapabilityFamily<ContentCap>('content');
214
+ if (!behaviorGate('openwop-content', content?.supported === true)) return;
215
+ const i18n = (await readCapabilityFamily<I18nCap>('i18n')) ?? {};
216
+ expect(
217
+ contentCoherent(content!, i18n),
218
+ driver.describe('localized-content.md §A', 'the advertised content block MUST satisfy the i18n-subset + baseLocale invariants'),
219
+ ).toBe(true);
220
+ });
221
+ });
@@ -703,6 +703,7 @@ describe('spec-corpus: OpenAPI 3.1 spec is structurally valid', () => {
703
703
  'getOpenApiSpec',
704
704
  'inspectInterruptByToken',
705
705
  'resolveInterruptByToken',
706
+ 'getContentPage',
706
707
  ]);
707
708
 
708
709
  const operations = extractOpenApiOperations(raw);