Skip to content
Merged
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
24 changes: 23 additions & 1 deletion server/handlers/cms/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ function orderFoldersParentFirst<T extends { id: string; parentId: string | null
return ordered
}

/** ZIP local-file-header magic (`PK\x03\x04`) — enough to tell an archive from JSON. */
function looksLikeZipArchive(body: ArrayBuffer): boolean {
if (body.byteLength < 4) return false
const head = new Uint8Array(body, 0, 4)
return head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04
}

export async function handleImportRoute(
req: Request,
db: DbClient,
Expand Down Expand Up @@ -130,7 +137,22 @@ export async function handleImportRoute(
if (stepUp) return stepUp
}

// Parse and validate the bundle body
// Parse and validate the bundle body. This endpoint takes the JSON bundle;
// the ZIP archive the export UI downloads belongs to /import/archive. Sent
// here it fails schema validation, and a bare schema error sends the caller
// hunting a bug in their bundle instead of at the wrong door — so name the
// mistake when the body is recognisably an archive.
const rawBody = await req.clone().arrayBuffer()
if (looksLikeZipArchive(rawBody)) {
return jsonResponse(
{
error:
'This endpoint accepts a JSON site bundle. The exported .zip archive goes to '
+ `${CMS_API_PREFIX}/import/archive (same strategy query parameter).`,
},
{ status: 400 },
)
}
const bundle = await readValidatedBody(req, SiteBundleSchema)
if (!bundle) {
return jsonResponse({ error: 'Invalid bundle: body does not conform to SiteBundleSchema' }, { status: 400 })
Expand Down
23 changes: 21 additions & 2 deletions server/repositories/data/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { nanoid } from 'nanoid'
import type { DbClient } from '../../db/client'
import { countDataRows } from './rows/read'
import { normalizeRouteBase } from '@core/templates/templateMatching'
import { normalizeDataTableFields } from '@core/data/fields'
import { buildPostTypeDefaultFields, normalizeDataTableFields } from '@core/data/fields'
import type {
DataField,
DataTable,
Expand Down Expand Up @@ -182,11 +182,30 @@ export async function getDataTableBySlug(db: DbClient, slug: string): Promise<Da
return rows[0] ? mapTable(rows[0]) : null
}

/**
* A post type is routable only if it carries the built-in `title`/`slug`
* fields: `slugForTable` returns an empty slug for a table without a `slug`
* field, and an entry with an empty slug has no public route. The Content UI
* seeds those fields client-side, so a table created through any other caller
* (the data API, an MCP connector, an import) used to arrive unroutable.
* Seeding here makes the invariant hold for every caller instead.
*
* Caller-supplied fields win on id collision, so an explicit `title` override
* (a different label, say) survives; omitted built-ins are prepended in their
* canonical order.
*/
function withPostTypeBuiltIns(kind: DataTableKind | undefined, fields: DataField[]): DataField[] {
if (kind !== 'postType') return fields
const supplied = new Set(fields.map((field) => field.id))
const missing = buildPostTypeDefaultFields().filter((field) => !supplied.has(field.id))
return [...missing, ...fields]
}

export async function createDataTable(
db: DbClient,
input: CreateDataTableInput,
): Promise<DataTable> {
const fields = normalizeDataTableFields(input.fields ?? [])
const fields = withPostTypeBuiltIns(input.kind, normalizeDataTableFields(input.fields ?? []))
const { rows } = await db<DataTableRow>`
insert into data_tables (
id,
Expand Down
9 changes: 8 additions & 1 deletion src/__tests__/base-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,14 @@ describe('base.text — unified text module', () => {

describe('base.button — render() specifics', () => {
it('has only content and behavior module settings', () => {
expect(Object.keys(ButtonModule.schema).sort()).toEqual(['disabled', 'href', 'htmlAttributes', 'label', 'target'])
expect(Object.keys(ButtonModule.schema).sort()).toEqual([
'buttonType',
'disabled',
'href',
'htmlAttributes',
'label',
'target',
])
})

it('renders an <a> element when href is set', () => {
Expand Down
125 changes: 125 additions & 0 deletions src/__tests__/htmlImport/authoredAffordances.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* authoredAffordances.test.ts — HTML import must not quietly rewrite the
* affordances an author wrote.
*
* Each case here is a defect found while authoring a full site through the MCP
* surface: the import reported success, the markup came back subtly different,
* and the behaviour that depended on it silently stopped working.
*/

import { describe, it, expect } from 'bun:test'
// Self-registers all base modules with the global registry singleton.
import '@modules/base'
import { registry } from '@core/module-engine'
import type { PageNode } from '@core/page-tree'
import { importHtml } from '@core/htmlImport'

/** Find the first imported node produced by `moduleId`. */
function firstNodeOf(html: string, moduleId: string): PageNode {
const result = importHtml(html)
const node = Object.values(result.nodes).find((candidate) => candidate.moduleId === moduleId)
if (!node) {
const seen = [...new Set(Object.values(result.nodes).map((n) => n.moduleId))].join(', ')
throw new Error(`No ${moduleId} node imported. Got: ${seen}`)
}
return node
}

/** Render a node's module to public HTML, as the publisher would. */
function renderNode(node: PageNode, children: string[] = []): string {
const module = registry.getOrThrow(node.moduleId)
const defaults = (module.defaults ?? {}) as Record<string, unknown>
return module.render({ ...defaults, ...node.props } as never, children).html
}

describe('select options', () => {
it('keeps an explicitly empty option value instead of substituting the label', () => {
// `<option value="">All crops</option>` is the conventional "no filter"
// choice. Substituting the label makes it a real value, so a filter that
// compares against '' silently matches nothing.
const node = firstNodeOf('<select><option value="">All crops</option></select>', 'base.option')
expect(node.props.value).toBe('')
expect(node.props.label).toBe('All crops')
})

it('still falls back to the text when no value attribute is present', () => {
const node = firstNodeOf('<select><option>Tomato</option></select>', 'base.option')
expect(node.props.value).toBe('Tomato')
})

it('preserves a non-empty value verbatim', () => {
const node = firstNodeOf('<select><option value="tomato">Tomato</option></select>', 'base.option')
expect(node.props.value).toBe('tomato')
})

it('round-trips an empty value through render', () => {
const node = firstNodeOf('<select><option value="">Any status</option></select>', 'base.option')
expect(renderNode(node)).toContain('value=""')
})
})

describe('reset buttons', () => {
it('keeps type="reset" on a button element', () => {
const node = firstNodeOf('<form><button type="reset">Reset</button></form>', 'base.button')
expect(node.props.buttonType).toBe('reset')
expect(renderNode(node)).toContain('type="reset"')
})

it('keeps type="reset" on an input element', () => {
const node = firstNodeOf('<form><input type="reset" value="Reset"></form>', 'base.button')
expect(node.props.buttonType).toBe('reset')
expect(renderNode(node)).toContain('type="reset"')
})

it('leaves an ordinary button as type="button"', () => {
const node = firstNodeOf('<button type="button">Open</button>', 'base.button')
expect(node.props.buttonType).toBe('button')
expect(renderNode(node)).toContain('type="button"')
})

it('does not turn a link-styled button into a reset control', () => {
const node = firstNodeOf('<button>Standalone</button>', 'base.button')
expect(renderNode(node)).toContain('type="button"')
})
})

describe('form attributes', () => {
it('preserves safe custom data attributes on a form', () => {
// A progressive-enhancement script binds to this hook; dropping it makes
// the script exit before initialising, with no error anywhere.
const node = firstNodeOf(
'<form data-recipe-filters data-analytics-id="filters"><input name="q"></form>',
'base.form',
)
const attrs = node.props.htmlAttributes as Record<string, string>
expect(attrs).toBeDefined()
expect(attrs['data-recipe-filters']).toBe('')
expect(attrs['data-analytics-id']).toBe('filters')
})

it('renders preserved attributes onto the published form', () => {
const node = firstNodeOf('<form data-recipe-filters><input name="q"></form>', 'base.form')
expect(renderNode(node)).toContain('data-recipe-filters')
})

it('does not duplicate the form wiring instatic generates itself', () => {
const node = firstNodeOf(
'<form action="/search" method="get" data-keep="yes"><input name="q"></form>',
'base.form',
)
const attrs = (node.props.htmlAttributes ?? {}) as Record<string, string>
expect(attrs['data-keep']).toBe('yes')
expect(attrs.action).toBeUndefined()
expect(attrs.method).toBeUndefined()

const html = renderNode(node)
expect(html.match(/\saction=/g) ?? []).toHaveLength(1)
expect(html.match(/\smethod=/g) ?? []).toHaveLength(1)
})

it('preserves an aria hook on a form', () => {
const node = firstNodeOf('<form aria-label="Filter recipes"><input name="q"></form>', 'base.form')
const attrs = node.props.htmlAttributes as Record<string, string>
expect(attrs['aria-label']).toBe('Filter recipes')
})
})
76 changes: 76 additions & 0 deletions src/__tests__/server/importEndpointGuidance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* importEndpointGuidance.test.ts — the two import endpoints take two different
* payloads, and sending the wrong one used to produce a misleading error.
*
* POST /admin/api/cms/import — JSON site bundle
* POST /admin/api/cms/import/archive — the .zip the export UI downloads
*
* Posting the archive to the JSON endpoint answered "body does not conform to
* SiteBundleSchema", which reads as a bug in the bundle rather than a wrong
* door. The handler now recognises the ZIP magic bytes and names the mistake.
*/
import { describe, expect, it } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createSqliteClient } from '../../../server/db/sqlite'
import { runMigrations } from '../../../server/db/runMigrations'
import { sqliteMigrations } from '../../../server/db/migrations-sqlite'
import type { DbClient } from '../../../server/db/client'
import { handleImportRoute } from '../../../server/handlers/cms/import'

async function setupDb(): Promise<{ db: DbClient; cleanup: () => Promise<void> }> {
const dir = await mkdtemp(join(tmpdir(), 'instatic-import-guide-'))
const db = createSqliteClient(join(dir, 'test.db'))
await runMigrations(db, sqliteMigrations)
return { db, cleanup: async () => { await rm(dir, { recursive: true, force: true }) } }
}

/** Minimal ZIP local-file-header prefix — enough for magic-byte detection. */
function zipBytes(): Uint8Array {
return new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00])
}

function importRequest(body: BodyInit): Request {
return new Request('http://localhost/admin/api/cms/import?strategy=merge-add', {
method: 'POST',
headers: { origin: 'http://localhost' },
body,
})
}

describe('POST /admin/api/cms/import with a ZIP body', () => {
it('points the caller at the archive endpoint instead of blaming the schema', async () => {
const { db, cleanup } = await setupDb()
try {
const res = await handleImportRoute(importRequest(zipBytes()), db)
// Unauthenticated callers are rejected before body parsing; the guidance
// only has to hold once the request reaches validation.
if (!res || res.status === 401 || res.status === 403) return

expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).toContain('/import/archive')
expect(body.error).not.toContain('SiteBundleSchema')
} finally {
await cleanup()
}
})
})

describe('ZIP detection', () => {
it('does not misread a JSON bundle as an archive', async () => {
const { db, cleanup } = await setupDb()
try {
const res = await handleImportRoute(
importRequest(JSON.stringify({ schemaVersion: 1, exportedAt: '', tables: [], rows: [] })),
db,
)
if (!res || res.status === 401 || res.status === 403) return
const body = (await res.json().catch(() => ({}))) as { error?: string }
expect(body.error ?? '').not.toContain('/import/archive')
} finally {
await cleanup()
}
})
})
Loading
Loading