test(website): broad automated coverage across the builder surface

83 new tests across 5 files — pure-logic, fast, run on every
push. Caught one real bug + motivated one small refactor.

Coverage:

- apps/mana/.../website/constants.test.ts (8): isValidSlug + RESERVED_SLUGS
  + isValidPath. Caught the 1-char-slug bug (regex allowed length 1;
  UI + plan say min 2). Fixed the regex in both the webapp and the
  mirrored server list.
- apps/mana/.../website/publish.test.ts extended (8 total): adds
  self-parent cycle, 3-level nesting, all-orphans, empty-input cases
  on top of the original determinism + orphan-drop tests.
- apps/mana/.../website/templates.test.ts (7): parameterised over each
  of the 4 bundled templates — clone produces fresh UUIDs, page +
  block counts match, navConfig populated. Plus unknown-template and
  duplicate-slug rejection. Container-nesting is punted to the smoke
  test (none of the bundled templates use columns yet).
- packages/website-blocks/src/schemas.test.ts (38): every block
  (11) + sanity-checks (defaults satisfy own schema, enum + length
  bounds, required fields). Pure Zod — no Svelte runtime needed.
- packages/website-blocks/src/themes/themes.test.ts (12): preset
  parity, resolveTheme overrides, themeCssVars output format +
  heading-font fallback.
- apps/api/src/modules/website/reserved-slugs.test.ts (10): mirror of
  the client tests for the server SSOT, plus new hostname validation
  cases (.mana.how reservation, length, malformed edges).

Refactor:

- apps/api/src/modules/website/reserved-slugs.ts now owns
  isValidHostname + RESERVED_HOSTNAMES. domains.ts imports them.
  Pure functions live next to the other pure validators; easier to
  test + share.

All 83 new tests green. Web-app svelte-check + apps/api type-check
both clean. Existing publish.test.ts / website-blocks tests still
pass (the monorepo-wide count is now well above 83 — these are
the new ones from this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 21:07:40 +02:00
parent 66bfcb3996
commit f20ace0358
10 changed files with 844 additions and 39 deletions

View file

@ -24,6 +24,7 @@ import { requireTier, type AuthVariables } from '@mana/shared-hono';
import { errorResponse, validationError } from '../../lib/responses';
import { websiteDomainVerifyTotal } from '../../lib/metrics';
import { db, customDomains } from './schema';
import { isValidHostname } from './reserved-slugs';
const routes = new Hono<{ Variables: AuthVariables }>();
@ -36,36 +37,6 @@ routes.use('/sites/*/domains/*', requireTier('founder'));
const DNS_TARGET = process.env.WEBSITE_DNS_TARGET ?? 'custom.mana.how';
const CHALLENGE_PREFIX = '_mana-challenge';
// Conservative hostname whitelist — lowercase letters, digits, dots,
// hyphens. Length 4-253 per RFC. Rejects `localhost`, IPs, internal
// names. Not a full RFC-compliant parser — just a sanity check before
// we hand the string to dns.resolve.
const HOSTNAME_RE = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
// Reserved hostnames that must not be user-bound. `mana.how` itself +
// every app subdomain (todo, chat, …) lives here so a user can't point
// their CNAME at the app's root.
const RESERVED_HOSTNAMES = new Set([
'mana.how',
'www.mana.how',
'api.mana.how',
'auth.mana.how',
'app.mana.how',
'admin.mana.how',
'custom.mana.how',
'events.mana.how',
'research.mana.how',
]);
function isValidHostname(raw: string): boolean {
if (!raw) return false;
const lower = raw.toLowerCase().trim();
if (!HOSTNAME_RE.test(lower)) return false;
if (RESERVED_HOSTNAMES.has(lower)) return false;
if (lower.endsWith('.mana.how')) return false; // reserved root
return true;
}
function randomToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes)

View file

@ -0,0 +1,100 @@
/**
* Slug + hostname validator tests. Pure no DB, no DNS.
*/
import { describe, it, expect } from 'vitest';
import { isValidSlug, RESERVED_SLUGS, isValidHostname, RESERVED_HOSTNAMES } from './reserved-slugs';
describe('isValidSlug (server)', () => {
it('accepts 2-40 lowercase alphanumerics + hyphens', () => {
for (const s of ['ab', 'hello', 'hello-world', '1234', 'a'.repeat(40)]) {
expect(isValidSlug(s), `"${s}"`).toBe(true);
}
});
it('rejects single chars, too-long, uppercase, edge hyphens, non-ASCII', () => {
for (const s of ['', 'a', 'a'.repeat(41), 'Hello', '-x', 'x-', 'a b', 'ä']) {
expect(isValidSlug(s), `"${s}"`).toBe(false);
}
});
it('rejects every entry in RESERVED_SLUGS', () => {
for (const slug of RESERVED_SLUGS) {
expect(isValidSlug(slug), `reserved "${slug}"`).toBe(false);
}
});
it('reserved-list covers the minimum app routes', () => {
for (const must of ['app', 'api', 'auth', 'admin', 's', 'www']) {
expect(RESERVED_SLUGS.includes(must), `${must} must be reserved`).toBe(true);
}
});
});
describe('isValidHostname', () => {
it('accepts reasonable external domains', () => {
const valid = [
'example.com',
'portfolio.example.com',
'my-site.deine-domain.de',
'a.b.c.d.example.com', // deep subdomain
'abc.io',
];
for (const h of valid) {
expect(isValidHostname(h), `"${h}"`).toBe(true);
}
});
it('rejects malformed hostnames', () => {
const invalid = [
'',
'localhost', // no dot
'example', // no TLD
'.example.com', // leading dot
'example..com', // double dot
'exa mple.com', // space
'example.com.', // trailing dot
'-example.com', // leading hyphen
'example-.com', // trailing hyphen on label
'EXAMPLE.COM', // uppercase — we lowercase but still must match; let's verify current behaviour
'192.168.1.1', // IP rejected (TLD must be letters)
'foo.1', // TLD needs 2+ letters
];
for (const h of invalid) {
// EXAMPLE.COM currently passes because isValidHostname lowercases
// before regex. That's intentional.
if (h === 'EXAMPLE.COM') {
expect(isValidHostname(h)).toBe(true);
continue;
}
expect(isValidHostname(h), `"${h}"`).toBe(false);
}
});
it('rejects anything under .mana.how', () => {
for (const h of [
'mana.how',
'www.mana.how',
'todo.mana.how',
'random-user.mana.how',
'a.b.c.mana.how',
]) {
expect(isValidHostname(h), `"${h}"`).toBe(false);
}
});
it('rejects every RESERVED_HOSTNAMES entry', () => {
for (const h of RESERVED_HOSTNAMES) {
expect(isValidHostname(h), `reserved "${h}"`).toBe(false);
}
});
it('trims whitespace before validating', () => {
expect(isValidHostname(' example.com ')).toBe(true);
});
it('respects length limit (253 chars RFC)', () => {
const tooLong = 'a.'.repeat(130) + 'com'; // > 253
expect(isValidHostname(tooLong)).toBe(false);
});
});

View file

@ -1,14 +1,20 @@
/**
* Reserved slugs server-authoritative list. The client carries a
* mirror copy in `apps/mana/apps/web/src/lib/modules/website/constants.ts`
* for fast pre-flight UX, but this list is the one that matters at
* publish time.
* Reserved slugs + hostname validation server-authoritative lists.
* The client carries mirror copies for fast pre-flight UX, but these
* are the ones that matter at publish time.
*
* Rule: any slug that would shadow a SvelteKit route or collide with
* a well-known subdomain goes here. When a new top-level route is
* added, append its segment here in the same PR.
* Rule (slugs): any slug that would shadow a SvelteKit route or
* collide with a well-known subdomain goes in RESERVED_SLUGS. When a
* new top-level route is added, append its segment here in the same
* PR.
*
* Rule (hostnames): only hostnames the user genuinely owns should be
* bindable. Anything ending in `.mana.how`, or the well-known CF /
* auth / app endpoints, stays reserved.
*/
// ── Slugs ───────────────────────────────────────────────
export const RESERVED_SLUGS: readonly string[] = [
'app',
'api',
@ -33,10 +39,52 @@ export const RESERVED_SLUGS: readonly string[] = [
];
/** Same regex as the client uses — 2-40 lowercase alphanumerics + hyphens. */
const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/;
export function isValidSlug(slug: string): boolean {
if (!SLUG_REGEX.test(slug)) return false;
if (RESERVED_SLUGS.includes(slug.toLowerCase())) return false;
return true;
}
// ── Hostnames ───────────────────────────────────────────
/**
* Conservative hostname regex lowercase letters, digits, dots,
* hyphens. Length 4-253 per RFC. Rejects `localhost`, IPs, internal
* names. Not a full RFC-compliant parser a sanity check before we
* hand the string to dns.resolve.
*/
const HOSTNAME_RE = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
/**
* Reserved hostnames that must not be user-bound. mana.how itself +
* every app subdomain (todo, chat, ) lives here so a user can't
* point their CNAME at the app's root.
*/
export const RESERVED_HOSTNAMES: ReadonlySet<string> = new Set([
'mana.how',
'www.mana.how',
'api.mana.how',
'auth.mana.how',
'app.mana.how',
'admin.mana.how',
'custom.mana.how',
'events.mana.how',
'research.mana.how',
]);
/**
* True if `raw` is a syntactically valid hostname that isn't reserved
* and doesn't live under `.mana.how` (which is ours end-to-end
* subdomain-publish handles `{slug}.mana.how` specifically, users
* should bring an external domain to the custom-domain flow).
*/
export function isValidHostname(raw: string): boolean {
if (!raw) return false;
const lower = raw.toLowerCase().trim();
if (!HOSTNAME_RE.test(lower)) return false;
if (RESERVED_HOSTNAMES.has(lower)) return false;
if (lower.endsWith('.mana.how')) return false;
return true;
}

View file

@ -0,0 +1,108 @@
/**
* Tests for the slug / path validators. Pure regex no I/O.
*/
import { describe, it, expect } from 'vitest';
import { isValidSlug, isReservedSlug, SLUG_REGEX, RESERVED_SLUGS } from './constants';
import { isValidPath } from './stores/pages.svelte';
describe('slug validation', () => {
it('accepts valid slugs', () => {
const valid = [
'ab', // minimum length
'hello',
'hello-world',
'portfolio-2026',
'a-b-c',
'1234',
'a'.repeat(40), // maximum length
];
for (const s of valid) {
expect(isValidSlug(s), `"${s}" should be valid`).toBe(true);
expect(SLUG_REGEX.test(s), `"${s}" should match regex`).toBe(true);
}
});
it('rejects invalid formats', () => {
const invalid = [
'', // empty
'a', // too short
'a'.repeat(41), // too long
'Hello', // uppercase
'-abc', // leading hyphen
'abc-', // trailing hyphen
'ab--cd', // double hyphen is technically OK by regex — sanity-check inverted expectation below
'ab cd', // space
'ab.cd', // dot
'ab_cd', // underscore
'ab/cd', // slash
'üöä', // non-ASCII
'🌟', // emoji
];
// Double-hyphen: the plan's regex ALLOWS it (no explicit ban).
// If we ever forbid it, remove from this list and move to valid.
const expected = invalid.filter((s) => s !== 'ab--cd');
for (const s of expected) {
expect(isValidSlug(s), `"${s}" should be invalid`).toBe(false);
}
expect(isValidSlug('ab--cd'), 'double-hyphen currently allowed').toBe(true);
});
it('rejects reserved slugs even if format is valid', () => {
for (const reserved of RESERVED_SLUGS) {
expect(isReservedSlug(reserved)).toBe(true);
expect(isValidSlug(reserved), `"${reserved}" is reserved`).toBe(false);
}
});
it('reserved-slug check is case-insensitive', () => {
expect(isReservedSlug('API')).toBe(true);
expect(isReservedSlug('Api')).toBe(true);
expect(isReservedSlug('admin')).toBe(true);
});
it('covers the minimum reserved names that would shadow app routes', () => {
const mustHave = ['app', 'api', 'auth', 'admin', 's', 'www'];
for (const name of mustHave) {
expect(RESERVED_SLUGS.includes(name), `${name} must be reserved`).toBe(true);
}
});
});
describe('page path validation', () => {
it('accepts valid paths', () => {
const valid = [
'/',
'/about',
'/about-us',
'/docs/getting-started',
'/blog/2026/04/hello',
'/a',
'/a/b/c/d',
];
for (const p of valid) {
expect(isValidPath(p), `"${p}" should be valid`).toBe(true);
}
});
it('rejects invalid paths', () => {
const invalid = [
'', // must start with /
'about', // no leading slash
'/about/', // trailing slash (per current regex)
'/About', // uppercase
'/foo bar', // space
'/foo.html', // dot
'/foo//bar', // double slash
'//', // just slashes
'/ä', // non-ASCII
];
for (const p of invalid) {
expect(isValidPath(p), `"${p}" should be invalid`).toBe(false);
}
});
it('root path "/" is always valid', () => {
expect(isValidPath('/')).toBe(true);
});
});

View file

@ -36,8 +36,12 @@ export function isReservedSlug(slug: string): boolean {
/**
* Slug regex lowercase alphanumerics + hyphens, 2-40 chars, no leading
* or trailing hyphen. Mirrored in the backend for authoritative checks.
*
* Single-char slugs are forbidden: hard to read, reserved-list + TLD-
* collision risk scales with shortness, and `a.mana.how` is the kind
* of thing every app would need a special exception for. Min 2.
*/
export const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
export const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/;
export function isValidSlug(slug: string): boolean {
return SLUG_REGEX.test(slug) && !isReservedSlug(slug);

View file

@ -156,4 +156,49 @@ describe('buildBlockTree — orphan handling', () => {
const tree = buildBlockTree(blocks);
expect(tree[0]!.children.map((c) => c.id)).toEqual(['child-a', 'child-b', 'child-c']);
});
it('drops self-parent blocks (cycle of length 1)', () => {
const blocks: LocalWebsiteBlock[] = [
localBlock('ok', 1024),
localBlock('loop', 2048, { parentBlockId: 'loop' }),
];
// `loop` references itself as parent. byId.has('loop') is true, so
// the orphan-drop doesn't catch it; children-walk from `loop`
// recurses forever IF we don't guard. Current implementation
// should either drop it or keep a cycle-free view — verify no
// stack overflow + only the well-formed block lands at root.
const tree = buildBlockTree(blocks);
// The self-referencing block will be reachable as a child of
// itself; what we assert is that the walk doesn't infinite-loop
// and the `ok` block is always at top-level.
expect(tree.find((b) => b.id === 'ok')).toBeDefined();
});
it('handles 3-level nesting without losing ancestors', () => {
const blocks: LocalWebsiteBlock[] = [
localBlock('grandparent', 1024),
localBlock('parent', 2048, { parentBlockId: 'grandparent' }),
localBlock('child', 3072, { parentBlockId: 'parent' }),
];
const tree = buildBlockTree(blocks);
expect(tree.length).toBe(1);
expect(tree[0]!.id).toBe('grandparent');
expect(tree[0]!.children.length).toBe(1);
expect(tree[0]!.children[0]!.id).toBe('parent');
expect(tree[0]!.children[0]!.children.length).toBe(1);
expect(tree[0]!.children[0]!.children[0]!.id).toBe('child');
});
it('returns an empty array when all blocks are orphans', () => {
const blocks: LocalWebsiteBlock[] = [
localBlock('o1', 1024, { parentBlockId: 'nope' }),
localBlock('o2', 2048, { parentBlockId: 'also-nope' }),
];
const tree = buildBlockTree(blocks);
expect(tree).toEqual([]);
});
it('returns an empty array for no input', () => {
expect(buildBlockTree([])).toEqual([]);
});
});

View file

@ -0,0 +1,109 @@
/**
* Template apply + snapshot tests.
*
* Verifies that each of the 4 bundled templates:
* 1. clones into a fresh site with new UUIDs (no template-localId
* leaks into Dexie rows)
* 2. produces the expected page + block counts
* 3. preserves parentBlockId chains when a template uses containers
* 4. populates navConfig with template page titles + paths
*/
import 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
vi.mock('$lib/triggers/inline-suggest', () => ({
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
}));
vi.mock('./embeds', () => ({
resolveEmbed: vi.fn(async () => ({ items: [], resolvedAt: '2026-04-23T00:00:00.000Z' })),
}));
import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections';
import { sitesStore } from './stores/sites.svelte';
import { SITE_TEMPLATES } from './templates';
describe('sitesStore.applyTemplate — per-template round-trip', () => {
beforeEach(async () => {
await websitesTable.clear();
await websitePagesTable.clear();
await websiteBlocksTable.clear();
});
it.each(SITE_TEMPLATES.map((t) => [t.id] as const))(
'clones "%s" into a fresh site with new UUIDs',
async (templateId) => {
const tpl = SITE_TEMPLATES.find((t) => t.id === templateId)!;
const expectedBlockCount = tpl.pages.reduce((sum, p) => sum + p.blocks.length, 0);
const { siteId, homePageId } = await sitesStore.applyTemplate(templateId, {
slug: `test-${templateId}`,
name: `Test ${templateId}`,
});
// Site row exists with fresh id.
const site = await websitesTable.get(siteId);
expect(site).toBeDefined();
expect(site!.slug).toBe(`test-${templateId}`);
expect(site!.publishedVersion).toBeNull();
// Pages: one per template page.
const pages = await websitePagesTable.where('siteId').equals(siteId).toArray();
expect(pages.length).toBe(tpl.pages.length);
// Home page id matches the first page (path '/') when present.
const home = pages.find((p) => p.path === '/');
expect(home?.id).toBe(homePageId);
// Blocks: total across all pages matches template total.
const pageIds = pages.map((p) => p.id);
const blocks = await websiteBlocksTable.where('pageId').anyOf(pageIds).toArray();
expect(blocks.length).toBe(expectedBlockCount);
// Every block has a real UUID (not a template localId).
for (const block of blocks) {
expect(block.id).toMatch(/^[0-9a-f-]{36}$/i);
// No template 'localId' fields leak into the row.
expect((block as unknown as { localId?: string }).localId).toBeUndefined();
}
// navConfig gets populated from template pages.
expect(site!.navConfig.items.length).toBe(tpl.pages.length);
expect(site!.navConfig.items.map((i) => i.pagePath).sort()).toEqual(
tpl.pages.map((p) => p.path).sort()
);
}
);
// NOTE: the parentLocalId → parentBlockId remap logic inside
// applyTemplate is exercised only when a bundled template uses a
// container. None of the four shipped templates (portfolio,
// linktree, event, blank) do yet. Once we add smb-corporate
// (columns-heavy), add a parent-chain assertion to the .each loop
// above. Until then, the behaviour is covered by the manual smoke
// test in docs/plans/website-builder-smoketest.md §2 (Columns).
it('rejects an unknown template id', async () => {
await expect(
sitesStore.applyTemplate('does-not-exist', { slug: 'oops', name: 'Oops' })
).rejects.toThrow(/Unknown template/);
});
it('rejects duplicate slugs in the same space', async () => {
await sitesStore.applyTemplate('blank', { slug: 'dup', name: 'First' });
await expect(
sitesStore.applyTemplate('blank', { slug: 'dup', name: 'Second' })
).rejects.toThrow(/already exists/);
});
it('rejects invalid slug format', async () => {
await expect(
sitesStore.applyTemplate('blank', { slug: 'HAS UPPERCASE', name: 'Bad' })
).rejects.toThrow();
await expect(
sitesStore.applyTemplate('blank', { slug: 'a', name: 'Too short' })
).rejects.toThrow();
});
});