diff --git a/.lighthouserc.json b/.lighthouserc.json index d4f8ca0..2463cea 100644 --- a/.lighthouserc.json +++ b/.lighthouserc.json @@ -9,6 +9,7 @@ "http://localhost:3000/", "http://localhost:3000/docs", "http://localhost:3000/docs/tutorials/quick-start", + "http://localhost:3000/petstore/api-playground", "http://localhost:3000/tfl/api-playground" ] }, diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 04b674f..fcb0079 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -95,11 +95,14 @@ const config: Config = { [ '@docusaurus/plugin-client-redirects', { - // Add redirect rules here when pages are moved or removed. - // Example: { from: '/docs/old-path', to: '/docs/new-path' } - redirects: [], - // Uncomment to redirect entire path prefixes: - // createRedirects(existingPath) { ... } + redirects: [ + {from: '/docs/getting-started', to: '/docs/tutorials/quick-start'}, + {from: '/docs/start-here', to: '/docs/tutorials/quick-start'}, + {from: '/docs/api', to: '/docs/reference'}, + {from: '/tfl/playground', to: '/tfl/api-playground'}, + {from: '/petstore/playground', to: '/petstore/api-playground'}, + {from: '/platzi/playground', to: '/platzi/api-playground'}, + ], }, ], // Benchmark mode: inject a synthetic fixture docset when BENCHMARK_SCALE is set. diff --git a/package.json b/package.json index 132a6e0..59ddba3 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "validate-assistant": "npx tsx scripts/validate-assistant-config.ts", "validate-assistant-quality": "npx tsx scripts/validate-assistant-quality.ts", "generate-nginx-auth-config": "npx tsx scripts/generate-nginx-auth-config.ts", - "check-rbac-permission": "npx tsx scripts/check-rbac-permission.ts" + "check-rbac-permission": "npx tsx scripts/check-rbac-permission.ts", + "test": "npx tsx --test scripts/__tests__/*.test.ts" }, "dependencies": { "@docusaurus/core": "3.9.2", diff --git a/scripts/__tests__/analytics-event.test.ts b/scripts/__tests__/analytics-event.test.ts new file mode 100644 index 0000000..20ff0bd --- /dev/null +++ b/scripts/__tests__/analytics-event.test.ts @@ -0,0 +1,118 @@ +import {describe, it} from 'node:test'; +import assert from 'node:assert/strict'; +import { + validateAnalyticsEvent, + createExportBatch, + SCHEMA_VERSION, + type AnalyticsEvent, +} from '../validate-analytics-event.js'; + +function validEvent(overrides?: Partial): AnalyticsEvent { + return { + schemaVersion: SCHEMA_VERSION, + type: 'page.view', + timestamp: '2026-04-03T09:00:00Z', + actor: {type: 'user', sessionId: 'abc-123'}, + payload: {docset: 'tfl', version: 'latest', slug: '/tfl/getting-started'}, + ...overrides, + }; +} + +describe('validateAnalyticsEvent', () => { + it('passes for a valid page.view event', () => { + const errors = validateAnalyticsEvent(validEvent()); + const errs = errors.filter(e => e.level === 'error'); + assert.equal(errs.length, 0); + }); + + it('errors on missing schemaVersion', () => { + const event = validEvent({schemaVersion: ''}); + const errors = validateAnalyticsEvent(event); + assert.ok(errors.some(e => e.field === 'schemaVersion')); + }); + + it('warns on mismatched schemaVersion', () => { + const event = validEvent({schemaVersion: '0.9'}); + const errors = validateAnalyticsEvent(event); + assert.ok(errors.some(e => e.level === 'warn' && e.field === 'schemaVersion')); + }); + + it('errors on invalid event type', () => { + const event = validEvent({type: 'user.logout' as any}); + const errors = validateAnalyticsEvent(event); + assert.ok(errors.some(e => e.field === 'type')); + }); + + it('errors on missing timestamp', () => { + const event = validEvent({timestamp: ''}); + const errors = validateAnalyticsEvent(event); + assert.ok(errors.some(e => e.field === 'timestamp')); + }); + + it('errors on invalid timestamp format', () => { + const event = validEvent({timestamp: 'yesterday'}); + const errors = validateAnalyticsEvent(event); + assert.ok(errors.some(e => e.field === 'timestamp')); + }); + + it('errors on missing actor', () => { + const event = validEvent(); + (event as any).actor = undefined; + const errors = validateAnalyticsEvent(event); + assert.ok(errors.some(e => e.field === 'actor')); + }); + + it('errors on invalid actor type', () => { + const event = validEvent(); + event.actor.type = 'admin' as any; + const errors = validateAnalyticsEvent(event); + assert.ok(errors.some(e => e.field === 'actor.type')); + }); + + it('errors on missing actor sessionId', () => { + const event = validEvent(); + event.actor.sessionId = ''; + const errors = validateAnalyticsEvent(event); + assert.ok(errors.some(e => e.field === 'actor.sessionId')); + }); + + it('errors on missing payload', () => { + const event = validEvent(); + (event as any).payload = undefined; + const errors = validateAnalyticsEvent(event); + assert.ok(errors.some(e => e.field === 'payload')); + }); + + it('errors on missing required payload fields for page.view', () => { + const event = validEvent({payload: {docset: 'tfl'} as any}); + const errors = validateAnalyticsEvent(event); + assert.ok(errors.some(e => e.field.startsWith('payload.'))); + }); + + it('validates search.query event payload', () => { + const event = validEvent({ + type: 'search.query', + payload: {query: 'how to authenticate', resultCount: 5} as any, + }); + const errors = validateAnalyticsEvent(event); + const errs = errors.filter(e => e.level === 'error'); + assert.equal(errs.length, 0); + }); +}); + +describe('createExportBatch', () => { + it('wraps events with metadata', () => { + const events = [validEvent(), validEvent({type: 'search.query', payload: {query: 'test', resultCount: 0} as any})]; + const batch = createExportBatch(events); + assert.equal(batch.schemaVersion, SCHEMA_VERSION); + assert.equal(batch.eventCount, 2); + assert.equal(batch.events.length, 2); + assert.ok(batch.exportedAt); + }); + + it('handles empty events array', () => { + const batch = createExportBatch([]); + assert.equal(batch.eventCount, 0); + assert.deepEqual(batch.events, []); + }); +}); diff --git a/scripts/__tests__/docset-config.test.ts b/scripts/__tests__/docset-config.test.ts new file mode 100644 index 0000000..209a843 --- /dev/null +++ b/scripts/__tests__/docset-config.test.ts @@ -0,0 +1,181 @@ +import {describe, it} from 'node:test'; +import assert from 'node:assert/strict'; +import { + isValidCalVer, + resolveLatestVersion, + validateDocsetConfig, + type DocsetConfig, +} from '../docset.config.js'; + +describe('isValidCalVer', () => { + it('accepts yyyy.mm format', () => { + assert.equal(isValidCalVer('2024.01'), true); + assert.equal(isValidCalVer('2025.12'), true); + assert.equal(isValidCalVer('2030.06'), true); + }); + + it('accepts yyyy.mm-LTS format', () => { + assert.equal(isValidCalVer('2024.06-LTS'), true); + assert.equal(isValidCalVer('2025.09-LTS'), true); + }); + + it('rejects invalid formats', () => { + assert.equal(isValidCalVer('v1.0'), false); + assert.equal(isValidCalVer('2024'), false); + assert.equal(isValidCalVer('2024.1'), false); + assert.equal(isValidCalVer('24.01'), false); + assert.equal(isValidCalVer('2024.06-lts'), false); + assert.equal(isValidCalVer('latest'), false); + assert.equal(isValidCalVer(''), false); + }); +}); + +describe('resolveLatestVersion', () => { + it('returns undefined for unversioned docsets', () => { + const config: DocsetConfig = {id: 'test', name: 'Test'}; + assert.equal(resolveLatestVersion(config), undefined); + }); + + it('returns undefined for empty versions array', () => { + const config: DocsetConfig = {id: 'test', name: 'Test', versions: []}; + assert.equal(resolveLatestVersion(config), undefined); + }); + + it('returns explicit latestVersion when set', () => { + const config: DocsetConfig = { + id: 'test', + name: 'Test', + latestVersion: '2024.06', + versions: [ + {id: '2025.01', label: 'Jan 2025', state: 'active'}, + {id: '2024.06', label: 'Jun 2024', state: 'lts'}, + ], + }; + assert.equal(resolveLatestVersion(config), '2024.06'); + }); + + it('resolves highest active version when no explicit latest', () => { + const config: DocsetConfig = { + id: 'test', + name: 'Test', + versions: [ + {id: '2024.01', label: 'Jan 2024', state: 'deprecated'}, + {id: '2024.06', label: 'Jun 2024', state: 'active'}, + {id: '2025.01', label: 'Jan 2025', state: 'active'}, + ], + }; + assert.equal(resolveLatestVersion(config), '2025.01'); + }); + + it('considers lts versions as candidates', () => { + const config: DocsetConfig = { + id: 'test', + name: 'Test', + versions: [ + {id: '2024.06-LTS', label: 'Jun 2024 LTS', state: 'lts'}, + {id: '2024.01', label: 'Jan 2024', state: 'deprecated'}, + ], + }; + assert.equal(resolveLatestVersion(config), '2024.06-LTS'); + }); + + it('ignores deprecated and eol versions', () => { + const config: DocsetConfig = { + id: 'test', + name: 'Test', + versions: [ + {id: '2025.01', label: 'Jan 2025', state: 'deprecated'}, + {id: '2024.06', label: 'Jun 2024', state: 'eol'}, + {id: '2024.01', label: 'Jan 2024', state: 'active'}, + ], + }; + assert.equal(resolveLatestVersion(config), '2024.01'); + }); +}); + +describe('validateDocsetConfig', () => { + it('passes for a valid unversioned config', () => { + const config: DocsetConfig = {id: 'test', name: 'Test'}; + assert.deepEqual(validateDocsetConfig(config), []); + }); + + it('errors on missing id', () => { + const config = {id: '', name: 'Test'} as DocsetConfig; + const errors = validateDocsetConfig(config); + assert.ok(errors.some(e => e.field === 'id')); + }); + + it('errors on missing name', () => { + const config = {id: 'test', name: ''} as DocsetConfig; + const errors = validateDocsetConfig(config); + assert.ok(errors.some(e => e.field === 'name')); + }); + + it('errors on invalid CalVer version id', () => { + const config: DocsetConfig = { + id: 'test', + name: 'Test', + versions: [{id: 'v1.0', label: 'V1', state: 'active'}], + }; + const errors = validateDocsetConfig(config); + assert.ok(errors.some(e => e.message.includes('not a valid CalVer'))); + }); + + it('errors on duplicate version ids', () => { + const config: DocsetConfig = { + id: 'test', + name: 'Test', + versions: [ + {id: '2024.06', label: 'Jun 2024', state: 'active'}, + {id: '2024.06', label: 'Jun 2024 copy', state: 'lts'}, + ], + }; + const errors = validateDocsetConfig(config); + assert.ok(errors.some(e => e.message.includes('duplicate'))); + }); + + it('errors on missing version label', () => { + const config: DocsetConfig = { + id: 'test', + name: 'Test', + versions: [{id: '2024.06', label: '', state: 'active'}], + }; + const errors = validateDocsetConfig(config); + assert.ok(errors.some(e => e.field.includes('label'))); + }); + + it('errors on invalid lifecycle state', () => { + const config: DocsetConfig = { + id: 'test', + name: 'Test', + versions: [{id: '2024.06', label: 'Jun', state: 'beta' as any}], + }; + const errors = validateDocsetConfig(config); + assert.ok(errors.some(e => e.message.includes('not a valid lifecycle state'))); + }); + + it('errors when latestVersion not in versions list', () => { + const config: DocsetConfig = { + id: 'test', + name: 'Test', + latestVersion: '2024.12', + versions: [{id: '2024.06', label: 'Jun', state: 'active'}], + }; + const errors = validateDocsetConfig(config); + assert.ok(errors.some(e => e.field === 'latestVersion')); + }); + + it('passes for a fully valid versioned config', () => { + const config: DocsetConfig = { + id: 'my-api', + name: 'My API', + latestVersion: '2025.01', + versions: [ + {id: '2025.01', label: 'Jan 2025', state: 'active'}, + {id: '2024.06-LTS', label: 'Jun 2024 LTS', state: 'lts'}, + {id: '2024.01', label: 'Jan 2024', state: 'deprecated'}, + ], + }; + assert.deepEqual(validateDocsetConfig(config), []); + }); +}); diff --git a/scripts/__tests__/rbac-config.test.ts b/scripts/__tests__/rbac-config.test.ts new file mode 100644 index 0000000..f47880a --- /dev/null +++ b/scripts/__tests__/rbac-config.test.ts @@ -0,0 +1,164 @@ +import {describe, it} from 'node:test'; +import assert from 'node:assert/strict'; +import { + getRoleDefinition, + hasCapability, + validateRbacConfig, + ROLE_DEFINITIONS, + type RbacConfig, + type RbacRole, +} from '../validate-rbac-config.js'; + +describe('ROLE_DEFINITIONS', () => { + it('defines exactly four roles', () => { + assert.equal(ROLE_DEFINITIONS.length, 4); + }); + + it('includes all expected roles', () => { + const roles = ROLE_DEFINITIONS.map(d => d.role); + assert.deepEqual(roles, ['admin', 'maintainer', 'contributor', 'viewer']); + }); + + it('admin has all capabilities', () => { + const admin = ROLE_DEFINITIONS.find(d => d.role === 'admin')!; + assert.equal(admin.capabilities.length, 8); + assert.ok(admin.capabilities.includes('platform.configure')); + assert.ok(admin.capabilities.includes('content.publish')); + }); + + it('viewer has only content.view', () => { + const viewer = ROLE_DEFINITIONS.find(d => d.role === 'viewer')!; + assert.deepEqual(viewer.capabilities, ['content.view']); + }); + + it('contributor cannot publish', () => { + const contributor = ROLE_DEFINITIONS.find(d => d.role === 'contributor')!; + assert.ok(!contributor.capabilities.includes('content.publish')); + }); +}); + +describe('getRoleDefinition', () => { + it('returns definition for valid role', () => { + const def = getRoleDefinition('admin'); + assert.ok(def); + assert.equal(def.role, 'admin'); + }); + + it('returns undefined for invalid role', () => { + const def = getRoleDefinition('superadmin' as RbacRole); + assert.equal(def, undefined); + }); +}); + +describe('hasCapability', () => { + it('admin has platform.configure', () => { + assert.equal(hasCapability('admin', 'platform.configure'), true); + }); + + it('maintainer does not have platform.configure', () => { + assert.equal(hasCapability('maintainer', 'platform.configure'), false); + }); + + it('contributor has content.edit', () => { + assert.equal(hasCapability('contributor', 'content.edit'), true); + }); + + it('contributor does not have content.publish', () => { + assert.equal(hasCapability('contributor', 'content.publish'), false); + }); + + it('viewer has content.view', () => { + assert.equal(hasCapability('viewer', 'content.view'), true); + }); + + it('viewer does not have content.edit', () => { + assert.equal(hasCapability('viewer', 'content.edit'), false); + }); +}); + +describe('validateRbacConfig', () => { + const validConfig: RbacConfig = { + schemaVersion: '1.0', + assignments: [ + { + principal: 'matt', + role: 'admin', + assignedAt: '2026-01-01T00:00:00Z', + assignedBy: 'system', + }, + ], + }; + + it('passes for a valid config', () => { + const errors = validateRbacConfig(validConfig); + const errs = errors.filter(e => e.level === 'error'); + assert.equal(errs.length, 0); + }); + + it('errors on missing schemaVersion', () => { + const config = {...validConfig, schemaVersion: ''}; + const errors = validateRbacConfig(config); + assert.ok(errors.some(e => e.field === 'schemaVersion')); + }); + + it('errors when no admin assignment exists', () => { + const config: RbacConfig = { + schemaVersion: '1.0', + assignments: [ + { + principal: 'reader', + role: 'viewer', + assignedAt: '2026-01-01T00:00:00Z', + assignedBy: 'system', + }, + ], + }; + const errors = validateRbacConfig(config); + assert.ok(errors.some(e => e.message.includes('admin'))); + }); + + it('errors on invalid role', () => { + const config: RbacConfig = { + schemaVersion: '1.0', + assignments: [ + { + principal: 'matt', + role: 'admin', + assignedAt: '2026-01-01T00:00:00Z', + assignedBy: 'system', + }, + { + principal: 'hacker', + role: 'superuser' as any, + assignedAt: '2026-01-01T00:00:00Z', + assignedBy: 'system', + }, + ], + }; + const errors = validateRbacConfig(config); + assert.ok(errors.some(e => e.message.includes('not a valid role'))); + }); + + it('supports team: principal prefix', () => { + const config: RbacConfig = { + schemaVersion: '1.0', + assignments: [ + { + principal: 'matt', + role: 'admin', + assignedAt: '2026-01-01T00:00:00Z', + assignedBy: 'system', + }, + { + principal: 'team:docs-team', + role: 'contributor', + assignedAt: '2026-01-01T00:00:00Z', + assignedBy: 'matt', + }, + ], + }; + const errors = validateRbacConfig(config); + const errs = errors.filter(e => e.level === 'error'); + assert.equal(errs.length, 0); + }); +}); diff --git a/scripts/validate-analytics-event.ts b/scripts/validate-analytics-event.ts index 34cb8e9..522911c 100644 --- a/scripts/validate-analytics-event.ts +++ b/scripts/validate-analytics-event.ts @@ -24,6 +24,7 @@ import fs from 'fs'; import path from 'path'; +import {fileURLToPath} from 'node:url'; // --------------------------------------------------------------------------- // Types @@ -219,6 +220,8 @@ function parseArgs(): {eventFile: string | null; printSchema: boolean} { return {eventFile, printSchema}; } +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const {eventFile, printSchema} = parseArgs(); if (printSchema) { @@ -266,3 +269,5 @@ if (hasErrors) { } else { console.log(`[analytics-event] event valid: type: ${event.type}`); } + +} // end main guard diff --git a/scripts/validate-rbac-config.ts b/scripts/validate-rbac-config.ts index 9de9495..9a55029 100644 --- a/scripts/validate-rbac-config.ts +++ b/scripts/validate-rbac-config.ts @@ -18,6 +18,7 @@ import fs from 'fs'; import path from 'path'; +import {fileURLToPath} from 'node:url'; // --------------------------------------------------------------------------- // Types @@ -186,6 +187,8 @@ function parseArgs(): {configPath: string; listRoles: boolean} { return {configPath, listRoles}; } +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const {configPath, listRoles} = parseArgs(); if (listRoles) { @@ -229,3 +232,5 @@ if (hasErrors) { const assignmentCount = config.assignments?.length ?? 0; console.log(`[rbac-config] config valid: ${assignmentCount} assignment(s)${warnCount > 0 ? ` (${warnCount} warning(s))` : ''}`); } + +} // end main guard