235 lines
13 KiB
JavaScript
235 lines
13 KiB
JavaScript
import { readFile, readdir } from 'node:fs/promises';
|
||
import { join } from 'node:path';
|
||
import yaml from 'js-yaml';
|
||
|
||
const fail = (message) => { throw new Error(message); };
|
||
const assert = (condition, message) => { if (!condition) fail(message); };
|
||
const same = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
|
||
|
||
function expectSubset(actual, expected, path) {
|
||
assert(actual && typeof actual === 'object', `${path} must be an object.`);
|
||
for (const [key, value] of Object.entries(expected)) {
|
||
const currentPath = `${path}.${key}`;
|
||
if (value && typeof value === 'object' && !Array.isArray(value)) expectSubset(actual[key], value, currentPath);
|
||
else assert(same(actual[key], value), `${currentPath} must be ${JSON.stringify(value)}.`);
|
||
}
|
||
}
|
||
|
||
const configPath = 'public/admin/config.yml';
|
||
const config = yaml.load(await readFile(configPath, 'utf8'));
|
||
const expectedOrigin = 'https://cms-pilot-3031023-xyz-www.ksloan90.workers.dev';
|
||
const expectedBackend = {
|
||
name: 'gitea',
|
||
repo: 'SloanOps/3031023-xyz',
|
||
branch: 'cms-pilot',
|
||
api_root: 'https://git.sloanops.com/api/v1',
|
||
base_url: 'https://git.sloanops.com',
|
||
auth_endpoint: 'https://git.sloanops.com/login/oauth/authorize',
|
||
};
|
||
|
||
expectSubset(config.backend, expectedBackend, 'backend');
|
||
assert(same(Object.keys(config.backend).sort(), [...Object.keys(expectedBackend), 'app_id'].sort()), 'The backend must not contain secret-bearing or unreviewed options.');
|
||
assert(typeof config.backend.app_id === 'string' && /^[A-Za-z0-9._-]+$/.test(config.backend.app_id), 'backend.app_id must be a non-confidential public client ID or the review placeholder.');
|
||
assert(config.publish_mode === 'simple', 'The Gitea pilot must use simple mode on its fixed review branch.');
|
||
assert(config.site_url === expectedOrigin && config.display_url === expectedOrigin, 'The CMS must use the stable primary preview origin.');
|
||
assert(config.local_backend === undefined, 'The pilot configuration must not enable an alternate local backend.');
|
||
expectSubset(config, {
|
||
media_folder: 'public/images/uploads',
|
||
public_folder: '/images/uploads',
|
||
}, 'config');
|
||
assert(config.media_library === undefined, 'Top-level media_library is reserved for registered external libraries; bound the built-in uploader on the image field.');
|
||
|
||
const markdownField = {
|
||
widget: 'markdown',
|
||
buttons: ['bold', 'italic', 'link', 'heading-two', 'heading-three', 'quote', 'bulleted-list', 'numbered-list'],
|
||
editor_components: [],
|
||
};
|
||
const cmsImagePattern = [
|
||
'^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:jpe?g|png|webp|avif)$',
|
||
'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.',
|
||
];
|
||
const optionalImageField = {
|
||
widget: 'image',
|
||
required: false,
|
||
choose_url: false,
|
||
allow_multiple: false,
|
||
pattern: cmsImagePattern,
|
||
media_library: { config: { max_file_size: 512000, multiple: false } },
|
||
};
|
||
const imageAltField = { widget: 'string', pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.'] };
|
||
const expectedCollections = {
|
||
primary_faq: {
|
||
folder: 'sites/www/content/faq',
|
||
identifier_field: 'question',
|
||
fields: {
|
||
question: { widget: 'string', pattern: ['^.{12,180}$', 'Use 12–180 characters.'] },
|
||
category: { widget: 'string', default: 'General', pattern: ['^.{2,48}$', 'Use 2–48 characters.'] },
|
||
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
|
||
draft: { widget: 'boolean', default: false },
|
||
body: markdownField,
|
||
},
|
||
},
|
||
primary_services: {
|
||
folder: 'sites/www/content/services',
|
||
identifier_field: 'title',
|
||
fields: {
|
||
title: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
|
||
price: { widget: 'string', pattern: ['^.{2,48}$', 'Use 2–48 characters.'] },
|
||
items: { widget: 'list', min: 1, max: 8, field: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] } },
|
||
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
|
||
featured: { widget: 'boolean', default: false },
|
||
draft: { widget: 'boolean', default: false },
|
||
body: markdownField,
|
||
},
|
||
},
|
||
primary_team: {
|
||
folder: 'sites/www/content/team',
|
||
identifier_field: 'name',
|
||
fields: {
|
||
name: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
|
||
role: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
|
||
image: optionalImageField,
|
||
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
|
||
draft: { widget: 'boolean', default: false },
|
||
body: markdownField,
|
||
},
|
||
},
|
||
journal_posts: {
|
||
folder: 'sites/blog/content/posts',
|
||
identifier_field: 'title',
|
||
fields: {
|
||
title: { widget: 'string', pattern: ['^.{12,100}$', 'Use 12–100 characters.'] },
|
||
description: { widget: 'text', pattern: ['^[\\s\\S]{30,180}$', 'Use 30–180 characters.'] },
|
||
pubDate: { widget: 'date', format: 'YYYY-MM-DD' },
|
||
updatedDate: { widget: 'date', format: 'YYYY-MM-DD', required: false },
|
||
tags: { widget: 'list', min: 1, max: 4, field: { widget: 'string', pattern: ['^.{2,32}$', 'Use 2–32 characters.'] } },
|
||
draft: { widget: 'boolean', default: false },
|
||
body: markdownField,
|
||
},
|
||
},
|
||
};
|
||
|
||
assert(Array.isArray(config.collections), 'collections must be an array.');
|
||
assert(same(config.collections.map(({ name }) => name).sort(), [...Object.keys(expectedCollections), 'primary_page_images'].sort()), 'Decap must expose the four content collections and one fixed page-image collection.');
|
||
for (const collection of config.collections.filter(({ name }) => expectedCollections[name])) {
|
||
const expected = expectedCollections[collection.name];
|
||
expectSubset(collection, {
|
||
folder: expected.folder,
|
||
identifier_field: expected.identifier_field,
|
||
create: true,
|
||
delete: true,
|
||
extension: 'md',
|
||
format: 'frontmatter',
|
||
slug: '{{slug}}',
|
||
}, `collections.${collection.name}`);
|
||
const fields = Object.fromEntries(collection.fields.map((field) => [field.name, field]));
|
||
assert(same(Object.keys(fields).sort(), Object.keys(expected.fields).sort()), `${collection.name} must preserve every modeled frontmatter field and body.`);
|
||
for (const [fieldName, fieldContract] of Object.entries(expected.fields)) expectSubset(fields[fieldName], fieldContract, `${collection.name}.${fieldName}`);
|
||
}
|
||
|
||
const pageImageCollection = config.collections.find(({ name }) => name === 'primary_page_images');
|
||
assert(pageImageCollection.folder === undefined && pageImageCollection.create === undefined && pageImageCollection.delete === undefined, 'Page images must remain a fixed file collection.');
|
||
assert(Array.isArray(pageImageCollection.files) && pageImageCollection.files.length === 1, 'Page images must expose exactly one fixed record.');
|
||
const pageImageFile = pageImageCollection.files[0];
|
||
expectSubset(pageImageFile, { name: 'primary', file: 'sites/www/content/page-images/primary.md' }, 'primary_page_images.primary');
|
||
const expectedPageImageFields = {
|
||
homeHeroImage: optionalImageField,
|
||
homeHeroImageAlt: imageAltField,
|
||
homeStoryImage: optionalImageField,
|
||
homeStoryImageAlt: imageAltField,
|
||
aboutHeroImage: optionalImageField,
|
||
aboutHeroImageAlt: imageAltField,
|
||
aboutStoryImage: optionalImageField,
|
||
aboutStoryImageAlt: imageAltField,
|
||
servicesHeroImage: optionalImageField,
|
||
servicesHeroImageAlt: imageAltField,
|
||
contactHeroImage: optionalImageField,
|
||
contactHeroImageAlt: imageAltField,
|
||
};
|
||
const pageImageFields = Object.fromEntries(pageImageFile.fields.map((field) => [field.name, field]));
|
||
assert(same(Object.keys(pageImageFields).sort(), Object.keys(expectedPageImageFields).sort()), 'Page images must expose only the six approved image and description pairs.');
|
||
for (const [fieldName, fieldContract] of Object.entries(expectedPageImageFields)) expectSubset(pageImageFields[fieldName], fieldContract, `primary_page_images.${fieldName}`);
|
||
|
||
function visitField(field) {
|
||
assert(field.widget !== 'file', `General file widget is forbidden: ${field.name}.`);
|
||
if (field.fields) field.fields.forEach(visitField);
|
||
if (field.field) visitField(field.field);
|
||
}
|
||
const allFields = config.collections.flatMap(({ fields, files }) => fields ?? files.flatMap((file) => file.fields));
|
||
allFields.forEach(visitField);
|
||
const imageFields = allFields.filter(({ widget }) => widget === 'image');
|
||
assert(imageFields.length === 7, 'Only the team image and six fixed page-image fields may expose media selection.');
|
||
for (const field of imageFields) expectSubset(field, optionalImageField, `image field ${field.name}`);
|
||
|
||
const schema = await readFile('src/content.config.ts', 'utf8');
|
||
for (const fragment of [
|
||
'title: z.string().min(12).max(100)',
|
||
'description: z.string().min(30).max(180)',
|
||
'updatedDate: z.coerce.date().optional()',
|
||
'tags: z.array(z.string().min(2).max(32)).min(1).max(4)',
|
||
"question: z.string().min(12).max(180)",
|
||
"category: z.string().min(2).max(48).default('General')",
|
||
'title: z.string().min(3).max(80)',
|
||
'price: z.string().min(2).max(48)',
|
||
'items: z.array(z.string().min(3).max(80)).min(1).max(8)',
|
||
'name: z.string().min(3).max(80)',
|
||
'role: z.string().min(3).max(80)',
|
||
'const optionalCmsImage = z.preprocess(',
|
||
'image: optionalCmsImage',
|
||
'homeHeroImage: optionalCmsImage',
|
||
'contactHeroImageAlt: z.string().min(8).max(180)',
|
||
'order: z.number().int().min(0).max(99).default(0)',
|
||
'draft: z.boolean().default(false)',
|
||
'featured: z.boolean().default(false)',
|
||
]) assert(schema.includes(fragment), `Astro schema changed; review the Decap contract for: ${fragment}`);
|
||
|
||
async function verifyContentPaths(directory) {
|
||
const entries = await readdir(directory, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const path = join(directory, entry.name);
|
||
if (entry.isDirectory()) {
|
||
assert(/^[a-z0-9][a-z0-9-]*$/.test(entry.name), `Content directory is outside the Markdown slug contract: ${path}`);
|
||
await verifyContentPaths(path);
|
||
} else {
|
||
assert(entry.isFile(), `Special content paths are outside the pilot contract: ${path}`);
|
||
assert(/^[a-z0-9][a-z0-9-]*\.md$/.test(entry.name), `Content filename is outside the Markdown slug contract: ${path}`);
|
||
}
|
||
}
|
||
}
|
||
for (const directory of Object.values(expectedCollections).map(({ folder }) => folder)) await verifyContentPaths(directory);
|
||
await verifyContentPaths('sites/www/content/page-images');
|
||
|
||
const admin = await readFile('public/admin/index.html', 'utf8');
|
||
assert(admin.includes('content="noindex,nofollow,noarchive"'), 'The admin shell must always carry no-index metadata.');
|
||
assert(admin.includes('href="/admin/config.yml"') && admin.includes('rel="cms-config-url"'), 'The admin shell must load the reviewed configuration.');
|
||
assert(admin.includes('https://unpkg.com/decap-cms@3.15.1/dist/decap-cms.js'), 'The admin shell must pin Decap CMS 3.15.1 exactly.');
|
||
assert(admin.includes('sha384-in6eHztHveqQ7uMZ1fDaKlDmacQLFuLH2wWrFTiymyuS8zQ5bixwL8U3AeRi8h/L'), 'The Decap bundle must retain its verified SHA-384 integrity value.');
|
||
assert(!/decap-cms@(latest|[~^*])/.test(admin), 'Floating Decap CMS versions are forbidden.');
|
||
|
||
const headers = await readFile('public/_headers', 'utf8');
|
||
assert(/\/admin\/\*[\s\S]*X-Robots-Tag: noindex, nofollow, noarchive/.test(headers), 'Admin responses must include an X-Robots-Tag no-index rule.');
|
||
|
||
const workflow = yaml.load(await readFile('.gitea/workflows/cms-content-validation.yml', 'utf8'));
|
||
assert(workflow.name === 'CMS content validation', 'The validation workflow display name is part of the required status context.');
|
||
assert(workflow.on?.pull_request && same(workflow.on.pull_request.branches, ['main']), 'The validation workflow must run for every pull request into main.');
|
||
assert(workflow.on.pull_request.paths === undefined, 'The required validation workflow must not use path filters.');
|
||
assert(workflow.jobs?.validate?.name === 'cms-content-validation', 'The validation job name is part of the required status context.');
|
||
const workflowText = await readFile('.gitea/workflows/cms-content-validation.yml', 'utf8');
|
||
assert(!workflowText.includes('secrets.'), 'CMS pull-request validation must remain secret-free.');
|
||
for (const command of ['npm run verify:cms', 'SITE_KEY=www npm run build:site', 'SITE_KEY=www npm run verify:build', 'SITE_KEY=blog npm run build:site', 'SITE_KEY=blog npm run verify:build']) {
|
||
assert(workflowText.includes(command), `Validation workflow is missing: ${command}`);
|
||
}
|
||
for (const path of [
|
||
'.gitea/workflows/cms-content-validation.yml',
|
||
'.gitea/workflows/deploy.yml',
|
||
'.gitea/workflows/deploy-blog.yml',
|
||
'.gitea/workflows/production-candidate.yml',
|
||
'.gitea/workflows/production-blog.yml',
|
||
]) {
|
||
const text = await readFile(path, 'utf8');
|
||
assert(/cache:\s*npm/.test(text) && /cache-dependency-path:\s*package-lock\.json/.test(text), `${path} must cache npm downloads by package-lock.json.`);
|
||
assert(text.includes('npm ci --prefer-offline --no-audit --no-fund'), `${path} must retain a clean lockfile install while preferring the cache.`);
|
||
}
|
||
|
||
console.log('Verified Decap backend, four content contracts, fixed page images, bounded media, admin pin, and stable PR status context.');
|