Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check
- run: npm test
- run: npm run api:check
- run: npm run api:member:check
- run: npm run api:delivery:check
- run: npm run build
env:
ORIGIN: http://127.0.0.1:3000
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,7 @@ must name a CMS address reachable from that container, not its own loopback.
The image defaults to port 3000 and `BODY_SIZE_LIMIT=9M`; it does not include a CMS.

`.dockerignore` permits only build inputs and excludes local secrets and output.
The CI workflow runs `npm ci`, check, committed public/member API freshness checks
and build. It does not deploy, use action secrets or build/push an image.
Run `npm test` for the unit suite. It type-checks tests once and runs every test
folder, without a browser or live CMS. CI runs the same command, Svelte checking,
generated API freshness checks and build. It does not deploy, use action secrets
or build/push an image. Visual checks and complete member journeys belong in staging.
11 changes: 3 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,19 @@
"scripts": {
"dev": "vite dev",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "svelte-kit sync && tsc --noEmit --project tsconfig.test.json && node --test --import tsx \"tests/**/*.test.ts\"",
"build": "vite build",
"preview": "vite preview",
"serve": "node build",
"api:generate": "tsx scripts/generate-sgf-api.ts",
"api:delivery:generate": "tsx scripts/generate-delivery-api.ts",
"test:pages": "node --test --import tsx tests/pages/*.test.ts",
"api:member:generate": "tsx scripts/generate-sgf-member-api.ts",
"api:member:check": "tsx scripts/generate-sgf-member-api.ts --check",
"test:auth": "node --test --import tsx tests/auth/*.test.ts",
"api:check": "tsx scripts/generate-sgf-api.ts --check",
"api:schema:fetch": "tsx scripts/fetch-sgf-api-schema.ts",
"test:api": "svelte-kit sync && tsc --noEmit --project tsconfig.test.json && node --test --import tsx tests/api/*.test.ts",
"test:home": "svelte-kit sync && tsc --noEmit --project tsconfig.test.json && node --test --import tsx tests/home/*.test.ts",
"test:media": "svelte-kit sync && tsc --noEmit --project tsconfig.test.json && node --test --import tsx tests/media/*.test.ts",
"test:member": "svelte-kit sync && tsc --noEmit --project tsconfig.test.json && node --test --import tsx tests/member/*.test.ts",
"test:theme": "node --test --import tsx tests/theme/*.test.ts",
"test:shell": "node --test --import tsx tests/shell/*.test.ts"
"api:delivery:check": "tsx scripts/generate-delivery-api.ts --check"
},

"devDependencies": {
"@sveltejs/adapter-node": "6.0.0",
"@sveltejs/kit": "3.0.0",
Expand Down
9 changes: 0 additions & 9 deletions scripts/lib/schema-bytes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
import { createHash } from 'node:crypto';

export const SGF_PUBLIC_SCHEMA_RAW_EXPORT_SHA256 = 'c1feb27b31f5b112c42498522c38d851253c3a6218761216a03ef8fb6afa6947';
export const SGF_PUBLIC_SCHEMA_CANONICAL_SHA256 = 'c1feb27b31f5b112c42498522c38d851253c3a6218761216a03ef8fb6afa6947';

export function normalizeOpenApiSnapshotText(text: string): string {
return text.replace(/\r\n?/g, '\n').replace(/\n+$/g, '');
}
Expand All @@ -17,7 +12,3 @@ export function canonicalOpenApiSnapshotText(text: string): string {
JSON.parse(normalized);
return normalized;
}

export function sha256Text(text: string): string {
return createHash('sha256').update(text, 'utf8').digest('hex');
}
6 changes: 0 additions & 6 deletions src/routes/+page.ts

This file was deleted.

223 changes: 36 additions & 187 deletions tests/api/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,221 +1,70 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SGF_PUBLIC_GET_PATHS } from '../../src/lib/server/api/factory';
import { fetchSgfApiSchema, parseLocalSchemaOrigin } from '../../scripts/fetch-sgf-api-schema';
import { renderSgfApiTypes } from '../../scripts/generate-sgf-api';
import {
SGF_PUBLIC_SCHEMA_CANONICAL_SHA256,
SGF_PUBLIC_SCHEMA_RAW_EXPORT_SHA256,
canonicalOpenApiSnapshotText,
normalizeOpenApiSnapshotText,
sha256Text
} from '../../scripts/lib/schema-bytes';
import { canonicalOpenApiSnapshotText } from '../../scripts/lib/schema-bytes';

const schemaPath = new URL('../../openapi/sgf-public-v1.openapi.json', import.meta.url);
const generatedPath = new URL('../../src/lib/server/api/generated/sgfPublicApiSchema.d.ts', import.meta.url);

async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
async function withTempFile(fn: (path: string) => Promise<void>) {
const dir = await mkdtemp(join(tmpdir(), 'sgf-schema-'));
try {
return await fn(dir);
await fn(join(dir, 'schema.json'));
} finally {
await rm(dir, { recursive: true, force: true });
}
}

describe('committed SGF OpenAPI snapshot', () => {
it('matches the reviewed backend export and contains only the public directory, Home, member, group and leadership routes', async () => {
const text = await readFile(schemaPath, 'utf8');
const document = JSON.parse(text);

assert.equal(sha256Text(text), SGF_PUBLIC_SCHEMA_CANONICAL_SHA256);
assert.equal(SGF_PUBLIC_SCHEMA_CANONICAL_SHA256, SGF_PUBLIC_SCHEMA_RAW_EXPORT_SHA256);
assert.deepEqual(Object.keys(document.paths).sort(), [
'/api/directory/filters/skills',
'/api/directory/search',
'/api/tags/skills',
'/api/v1/public/groups',
'/api/v1/public/groups/{slug}',
'/api/v1/public/home',
'/api/v1/public/leadership',
'/api/v1/public/members/{username}'
]);
assert.deepEqual([...SGF_PUBLIC_GET_PATHS].sort(), Object.keys(document.paths).sort());
describe('public OpenAPI schema', () => {
it('matches the anonymous client path allowlist', async () => {
const schema = JSON.parse(await readFile(schemaPath, 'utf8'));
assert.deepEqual([...SGF_PUBLIC_GET_PATHS].sort(), Object.keys(schema.paths).sort());
});

it('keeps directory errors as ProblemDetails and adds the real Home operation', async () => {
const document = JSON.parse(await readFile(schemaPath, 'utf8'));
const searchResponses = document.paths['/api/directory/search'].get.responses;
const homeResponses = document.paths['/api/v1/public/home'].get.responses;

assert.deepEqual(Object.keys(searchResponses['200'].content), ['application/json']);
assert.deepEqual(Object.keys(searchResponses['400'].content), ['application/problem+json']);
assert.deepEqual(searchResponses['400'].content['application/problem+json'].schema, {
$ref: '#/components/schemas/ProblemDetails'
});

assert.equal(document.paths['/api/v1/public/home'].get.operationId, 'PublicHome_Get');
assert.deepEqual(Object.keys(homeResponses['200'].content), ['application/json']);
assert.deepEqual(homeResponses['200'].content['application/json'].schema, {
$ref: '#/components/schemas/PublicHomeDto'
});
assert.deepEqual(homeResponses['404'].content['application/problem+json'].schema, {
$ref: '#/components/schemas/ProblemDetails'
});
});

it('adds the real required member path and explicit profile response', async () => {
const document = JSON.parse(await readFile(schemaPath, 'utf8'));
const get = document.paths['/api/v1/public/members/{username}'].get;
assert.equal(get.operationId, 'PublicMember_Get');
assert.deepEqual(get.parameters, [{ name: 'username', in: 'path', required: true, schema: { type: 'string' } }]);
assert.deepEqual(get.responses['200'].content['application/json'].schema, { $ref: '#/components/schemas/PublicMemberProfileDto' });
assert.deepEqual(get.responses['404'].content['application/problem+json'].schema, { $ref: '#/components/schemas/ProblemDetails' });
});

it('keeps the generated public API as legacy directory arrays plus backend Home DTOs', async () => {
const generated = await readFile(generatedPath, 'utf8');

assert.match(generated, /"application\/json": string\[\];/);
assert.match(generated, /"application\/json": components\["schemas"\]\["PublicSkillFilterDto"\]\[\];/);
assert.match(generated, /"application\/json": components\["schemas"\]\["PublicDirectoryMemberDto"\]\[\];/);
assert.match(generated, /PublicHome_Get/);
assert.match(generated, /"application\/json": components\["schemas"\]\["PublicHomeDto"\];/);
assert.match(generated, /"application\/problem\+json": components\["schemas"\]\["ProblemDetails"\];/);
assert.doesNotMatch(generated, /PagedResult|items:/);
});

it('detects stale generated types from the committed schema', async () => {
const generated = await readFile(generatedPath, 'utf8');

assert.equal(generated, await renderSgfApiTypes());
});
});

describe('OpenAPI snapshot byte policy', () => {
it('accepts only loopback schema origins with no path, query, hash, or credentials', () => {
const invalid = [
'//127.0.0.1:5099',
'ftp://127.0.0.1:5099',
'http://user:pass@127.0.0.1:5099',
'http://127.0.0.1:5099/umbraco/openapi/sgf-public-v1.json',
'http://127.0.0.1:5099?schema=1',
'http://127.0.0.1:5099#schema',
'https://cms.sgf.dev',
'https://example.com'
];

for (const value of invalid) {
assert.throws(() => parseLocalSchemaOrigin(value), value);
it('accepts only loopback schema origins without credentials, paths or query strings', () => {
for (const origin of ['//127.0.0.1', 'ftp://127.0.0.1', 'http://user:pass@127.0.0.1',
'http://127.0.0.1/api', 'http://127.0.0.1?q=1', 'http://127.0.0.1#x', 'https://cms.sgf.dev']) {
assert.throws(() => parseLocalSchemaOrigin(origin), origin);
}
for (const host of ['localhost', '127.0.0.1', '[::1]']) {
assert.equal(parseLocalSchemaOrigin(`http://${host}:5099/`), `http://${host}:5099`);
}
assert.equal(parseLocalSchemaOrigin('http://localhost:5099'), 'http://localhost:5099');
assert.equal(parseLocalSchemaOrigin('http://127.0.0.1:5099/'), 'http://127.0.0.1:5099');
assert.equal(parseLocalSchemaOrigin('http://[::1]:5099'), 'http://[::1]:5099');
});

it('normalizes only line endings and terminal newlines, with no final newline in the committed artifact', async () => {
const canonical = await readFile(schemaPath, 'utf8');
const crlf = canonical.replace(/\n/g, '\r\n');

assert.equal(canonical.endsWith('\n'), false);
assert.equal(normalizeOpenApiSnapshotText(canonical), canonical);
assert.equal(normalizeOpenApiSnapshotText(`${canonical}\n`), canonical);
assert.equal(normalizeOpenApiSnapshotText(`${canonical}\n\n`), canonical);
assert.equal(normalizeOpenApiSnapshotText(crlf), canonical);
assert.equal(normalizeOpenApiSnapshotText(`${crlf}\r\n\r\n`), canonical);
assert.equal(normalizeOpenApiSnapshotText(normalizeOpenApiSnapshotText(`${crlf}\r\n`)), canonical);
assert.equal(canonicalOpenApiSnapshotText(`${crlf}\r\n`), canonical);
});

it('fetches from a synthetic loopback server and writes exact canonical committed bytes', async () => {
await withTempDir(async (dir) => {
const canonical = await readFile(schemaPath, 'utf8');
const served = `${canonical.replace(/\n/g, '\r\n')}\r\n\r\n`;
const outputPath = join(dir, 'sgf-public-v1.openapi.json');
let requestedPath = '';
let sawCredentiallessFetch = false;
const server = createServer((request, response) => {
requestedPath = request.url ?? '';
sawCredentiallessFetch = !request.headers.cookie && !request.headers.authorization;
response.writeHead(200, { 'content-type': 'application/json' });
response.end(served);
});

await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
try {
const address = server.address();
assert.equal(typeof address, 'object');
const port = (address as AddressInfo).port;
await fetchSgfApiSchema({ schemaOrigin: `http://127.0.0.1:${port}`, outputPath });
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}

assert.equal(requestedPath, '/umbraco/openapi/sgf-public-v1.json');
assert.equal(sawCredentiallessFetch, true);
assert.equal(await readFile(outputPath, 'utf8'), canonical);
assert.equal(sha256Text(await readFile(outputPath, 'utf8')), SGF_PUBLIC_SCHEMA_CANONICAL_SHA256);
});
it('normalizes line endings and terminal newlines, rejecting malformed JSON', () => {
assert.equal(canonicalOpenApiSnapshotText('{\r\n "openapi": "3.0.0"\r\n}\r\n\r\n'), '{\n "openapi": "3.0.0"\n}');
assert.throws(() => canonicalOpenApiSnapshotText('{bad json'), SyntaxError);
});

it('uses redirect error and omitted credentials for schema fetches', async () => {
await withTempDir(async (dir) => {
const canonical = await readFile(schemaPath, 'utf8');
const outputPath = join(dir, 'sgf-public-v1.openapi.json');
let requestedUrl = '';
let requestedInit: RequestInit | undefined;

it('fetches anonymously without redirects and writes normalized JSON', async () => {
await withTempFile(async outputPath => {
await fetchSgfApiSchema({
schemaOrigin: 'http://127.0.0.1:5099',
outputPath,
schemaOrigin: 'http://127.0.0.1:5099', outputPath,
fetchImpl: async (url, init) => {
requestedUrl = String(url);
requestedInit = init;
return new Response(canonical, { status: 200 });
assert.equal(String(url), 'http://127.0.0.1:5099/umbraco/openapi/sgf-public-v1.json');
assert.equal(init?.credentials, 'omit');
assert.equal(init?.redirect, 'error');
return new Response('{"openapi":"3.0.0"}\r\n');
}
});

assert.equal(requestedUrl, 'http://127.0.0.1:5099/umbraco/openapi/sgf-public-v1.json');
assert.equal(requestedInit?.credentials, 'omit');
assert.equal(requestedInit?.redirect, 'error');
assert.equal(await readFile(outputPath, 'utf8'), canonical);
assert.equal(await readFile(outputPath, 'utf8'), '{"openapi":"3.0.0"}');
});
});

it('keeps the previous schema file on malformed JSON or network errors', async () => {
await withTempDir(async (dir) => {
const outputPath = join(dir, 'sgf-public-v1.openapi.json');
await writeFile(outputPath, 'old schema');

await assert.rejects(
fetchSgfApiSchema({
schemaOrigin: 'http://127.0.0.1:5099',
outputPath,
fetchImpl: async () => new Response('{bad json', { status: 200 })
}),
SyntaxError
);
assert.equal(await readFile(outputPath, 'utf8'), 'old schema');

await assert.rejects(
fetchSgfApiSchema({
schemaOrigin: 'http://127.0.0.1:5099',
outputPath,
fetchImpl: async () => {
throw new TypeError('network broke');
}
}),
/network broke/
);
assert.equal(await readFile(outputPath, 'utf8'), 'old schema');
it('keeps the previous file after HTTP, JSON or network failures', async () => {
await withTempFile(async outputPath => {
await writeFile(outputPath, 'previous schema');
for (const fetchImpl of [
async () => new Response('unavailable', { status: 503 }),
async () => new Response('{bad json'),
async () => { throw new TypeError('network failure'); }
]) {
await assert.rejects(fetchSgfApiSchema({ schemaOrigin: 'http://127.0.0.1:5099', outputPath, fetchImpl }));
assert.equal(await readFile(outputPath, 'utf8'), 'previous schema');
}
});
});
});
45 changes: 0 additions & 45 deletions tests/api/server-only.test.ts

This file was deleted.

4 changes: 0 additions & 4 deletions tests/companies/companies.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { mapDeliveryCompany, trustedCompanyVideo, CompanyDataError } from '../../src/lib/server/companies/mapper';
import { createDeliveryCompanyClient } from '../../src/lib/server/companies/client';
import { loadCompany, CompanyLoadError } from '../../src/lib/server/companies/load';
Expand Down Expand Up @@ -29,8 +27,6 @@ test('company mapper keeps legacy public fields and real filters, never private
for (const src of ['https://evil.test/embed/3a5mR5xoUbc', 'https://www.youtube.com.evil.test/embed/3a5mR5xoUbc', 'http://www.youtube.com/embed/3a5mR5xoUbc', 'https://private@www.youtube.com/embed/3a5mR5xoUbc', 'https://www.youtube.com/watch?v=3a5mR5xoUbc'])
assert.equal(trustedCompanyVideo('<iframe src="' + src + '"></iframe>'), null);
assert.throws(() => mapDeliveryCompany({ ...source, route: { path: '/companies/wrong/' } }, companyPath, media), CompanyDataError);
const raw = await readFile('openapi/umbraco-delivery.openapi.json');
assert.equal(createHash('sha256').update(raw).digest('hex'), '7f630c8bc7a21658b0b9619b6311998fce749909c67ca4ff159c9fd22a2edec0');
});

test('company Delivery transport stays server-only, fixed-origin, bounded to one slug and approved fields', async () => {
Expand Down
Loading
Loading