Skip to content

test/static-site.test.ts

284 lines · 11.4 KB · TypeScript
Raw
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { describe, it } from 'node:test'
import { fileURLToPath } from 'node:url'
import {
	assembleSite,
	buildManifest,
	contentTypeFor,
	hashAsset,
	hashAssets,
	isIgnoredSitePath,
	normalizeSitePath,
	planUploadBatches,
	renderHeaders,
	mergeHeadersText,
	renderRedirects,
	SPA_REDIRECT_RULE,
} from '../src/static-site.ts'
import {
	entriesToSiteFiles,
	readArchive,
	selectSiteEntries,
} from '../src/archive.ts'
import { buildFormData, buildRequestBody } from '../src/request-body.ts'
import { nextPageQuery, extractListItems } from '../src/api-v4-list.ts'
import { scanSiteFiles, DEFAULT_FORBID } from '../src/scan.ts'
import {
	assertPagesProjectName,
	buildDeploymentForm,
	deploymentState,
} from '../src/pages-direct-upload.ts'
import { accounts, resolveKnownAccountId, KCD_CLOUDFLARE_ACCOUNT_ID } from '../src/auth.ts'

const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')

describe('normalizeSitePath', () => {
	it('strips leading slash and dots', () => {
		assert.equal(normalizeSitePath('/assets/app.js'), 'assets/app.js')
		assert.equal(normalizeSitePath('./index.html'), 'index.html')
	})
	it('rejects .. and empty', () => {
		assert.throws(() => normalizeSitePath('../x'), /Invalid/)
		assert.throws(() => normalizeSitePath(''), /Invalid/)
		assert.throws(() => normalizeSitePath('a/'), /directory/)
	})
})

describe('content types and ignore', () => {
	it('maps common extensions', () => {
		assert.equal(contentTypeFor('a.html'), 'text/html')
		assert.equal(contentTypeFor('a.js'), 'application/javascript')
		assert.equal(contentTypeFor('a.weird'), 'application/octet-stream')
	})
	it('ignores junk segments', () => {
		assert.equal(isIgnoredSitePath('.DS_Store'), true)
		assert.equal(isIgnoredSitePath('node_modules/x.js'), true)
		assert.equal(isIgnoredSitePath('assets/app.js'), false)
	})
})

describe('hash + manifest', () => {
	it('hashes base64+ext to 32 hex chars and is stable', async () => {
		const a = await hashAsset(btoa('hello'), 'index.html')
		const b = await hashAsset(btoa('hello'), 'index.html')
		assert.equal(a, b)
		assert.match(a, /^[0-9a-f]{32}$/)
		const c = await hashAsset(btoa('hello'), 'index.txt')
		assert.notEqual(a, c)
	})
	it('builds a sorted path→hash manifest', async () => {
		const assets = await hashAssets([
			{ path: 'b.txt', bytes: new TextEncoder().encode('b'), contentType: 'text/plain' },
			{ path: 'a.txt', bytes: new TextEncoder().encode('a'), contentType: 'text/plain' },
		])
		assert.deepEqual(Object.keys(buildManifest(assets)), ['/a.txt', '/b.txt'])
	})
	it('batches uploads by size with unique hashes', () => {
		const files = [
			{ hash: 'a', size: 6 },
			{ hash: 'b', size: 5 },
			{ hash: 'a', size: 6 },
			{ hash: 'c', size: 4 },
		]
		const batches = planUploadBatches(files, { maxBatchBytes: 10, maxBatchFiles: 10 })
		assert.equal(batches.flat().length, 3)
		assert.ok(batches.every((batch) => batch.reduce((s, f) => s + f.size, 0) <= 10))
	})
})

describe('headers / redirects', () => {
	it('renders object headers and rule redirects', () => {
		assert.equal(
			renderHeaders({ '/*': { 'X-Frame-Options': 'DENY' } }),
			'/*\n  X-Frame-Options: DENY',
		)
		assert.equal(renderRedirects([{ from: '/a', to: '/b', status: 301 }]), '/a /b 301')
	})
	it('merges repeated _headers patterns into one block', () => {
		assert.equal(
			mergeHeadersText('/*\n  X-A: 1\n# note\n/assets/*\n  Cache-Control: max-age=60\n/*\n  X-B: 2'),
			'/*\n  X-A: 1\n  X-B: 2\n/assets/*\n  Cache-Control: max-age=60',
		)
	})
	it('rejects bad redirects', () => {
		assert.throws(() => renderRedirects([{ from: '/a space', to: '/b' } as never]), /whitespace/)
	})
})

describe('assembleSite', () => {
	it('merges spa + noindex and removes config from assets', () => {
		const site = assembleSite({
			files: {
				'index.html': '<h1>hi</h1>',
				'_headers': '/*\n  X-Test: 1',
			},
			spa: true,
			noindex: true,
		})
		assert.equal(site.assets.some((f) => f.path === '_headers'), false)
		assert.match(site.config._headers ?? '', /X-Test/)
		assert.match(site.config._headers ?? '', /noindex/)
		assert.equal((site.config._headers ?? '').match(/^\/\*$/gm)?.length, 1)
		assert.equal((site.config._redirects ?? '').trim(), SPA_REDIRECT_RULE)
	})
	it('spa:false adds a 404.html', () => {
		const site = assembleSite({ files: { 'index.html': 'x' }, spa: false })
		assert.ok(site.assets.some((f) => f.path === '404.html'))
	})
	it('rejects _worker.js', () => {
		assert.throws(
			() => assembleSite({ files: { 'index.html': 'x', '_worker.js': 'export default {}' } }),
			/_worker\.js/,
		)
	})
	it('files override archive entries', () => {
		const site = assembleSite({
			archiveFiles: [{ path: 'index.html', bytes: new TextEncoder().encode('old'), contentType: 'text/html' }],
			files: { 'index.html': 'new' },
		})
		assert.equal(new TextDecoder().decode(site.assets[0].bytes), 'new')
		assert.ok(site.warnings.some((w) => /overrode/.test(w)))
	})
})

describe('archives', () => {
	it('reads zip and strips a shared top folder', async () => {
		const { entries } = await readArchive(readFileSync(join(fixtures, 'site.zip')))
		const selected = selectSiteEntries(entries)
		assert.equal(selected.root, 'site')
		const files = entriesToSiteFiles(selected.entries)
		assert.ok(files.some((f) => f.path === 'index.html'))
		assert.ok(files.some((f) => f.path === 'assets/app.js'))
		assert.equal(files.some((f) => f.path === '.DS_Store'), false)
		assert.equal(files.some((f) => f.path === '_headers'), true)
	})
	it('reads stored (uncompressed) zip entries', async () => {
		const { entries } = await readArchive(readFileSync(join(fixtures, 'site-stored.zip')))
		const files = entriesToSiteFiles(selectSiteEntries(entries).entries)
		assert.deepEqual(files.map((f) => f.path).sort(), ['a/b.txt', 'index.html'])
		assert.equal(new TextDecoder().decode(files.find((f) => f.path === 'a/b.txt')!.bytes), 'bee')
	})
	it('reads deflated zip bytes correctly', async () => {
		const { entries } = await readArchive(readFileSync(join(fixtures, 'site.zip')))
		const index = entries.find((e) => e.path === 'site/index.html')!
		assert.match(new TextDecoder().decode(index.data), /<h1>Hello<\/h1>/)
	})
	it('reads tar.gz and tar', async () => {
		const tgz = await readArchive(readFileSync(join(fixtures, 'site.tgz')))
		assert.equal(tgz.format, 'tar.gz')
		assert.ok(tgz.entries.some((e) => e.path === 'index.html' || e.path.endsWith('/index.html')))
		const tar = await readArchive(readFileSync(join(fixtures, 'site.tar')))
		assert.equal(tar.format, 'tar')
	})
	it('sourceRoot with * strips a GitHub-style top folder', async () => {
		const { entries } = await readArchive(readFileSync(join(fixtures, 'gh.tgz')))
		const selected = selectSiteEntries(entries, '*/dist')
		assert.match(selected.root, /repo-/)
		const files = entriesToSiteFiles(selected.entries)
		assert.deepEqual(files.map((f) => f.path), ['index.html'])
	})
})

describe('scan', () => {
	it('includes built-in credential patterns by default', () => {
		assert.ok(DEFAULT_FORBID.length >= 5)
		const result = scanSiteFiles([
			{ path: 'a.txt', bytes: new TextEncoder().encode('token sk_live_abcdefghijklmnopqrstuvwxyz end'), contentType: 'text/plain' },
		])
		assert.equal(result.ok, false)
		assert.equal(result.findings[0].pattern, 'stripe-secret-key')
		assert.match(result.findings[0].preview, /\*/)
	})
	it('accepts /regex/ strings and literals', () => {
		const files = [{ path: 'a.html', bytes: new TextEncoder().encode('INTERNAL_ONLY_MARKER'), contentType: 'text/html' }]
		assert.equal(scanSiteFiles(files, { forbid: ['INTERNAL_ONLY_MARKER'], defaults: false }).ok, false)
		assert.equal(scanSiteFiles(files, { forbid: ['/internal_only_marker/i'], defaults: false }).ok, false)
		assert.equal(scanSiteFiles(files, { forbid: ['nope'], defaults: false }).ok, true)
	})
	it('skips binary files', () => {
		const result = scanSiteFiles(
			[{ path: 'a.png', bytes: new Uint8Array([1, 2, 3]), contentType: 'image/png' }],
			{ forbid: ['x'], defaults: false },
		)
		assert.equal(result.scannedFiles, 0)
		assert.equal(result.skippedBinaryFiles, 1)
	})
})

describe('form / request body', () => {
	it('builds multipart form fields', () => {
		const form = buildFormData({
			manifest: '{}',
			_headers: { filename: '_headers', content: '/*\n  X: 1' },
		})
		assert.ok(form.get('manifest') === '{}')
		assert.ok(form.get('_headers') instanceof File)
	})
	it('rejects body+form together and ignores JSON body on GET', () => {
		assert.throws(() => buildRequestBody({ body: {}, form: { a: '1' } }, 'POST'), /only one/)
		assert.equal(buildRequestBody({ body: { a: 1 } }, 'GET').kind, 'none')
		assert.equal(buildRequestBody({ body: { a: 1 } }, 'POST').kind, 'json')
		assert.equal(buildRequestBody({ form: { a: '1' } }, 'POST').kind, 'form')
	})
})

describe('api-v4-list helpers (fixtures)', () => {
	it('walks page-number result_info from a real Pages list shape', () => {
		const info = JSON.parse(readFileSync(join(fixtures, 'result-info.json'), 'utf8'))
		assert.deepEqual(
			nextPageQuery(info.pagesProjectsPage1, { page: 1, received: 10, perPage: 10 }),
			{ page: 2 },
		)
		assert.equal(nextPageQuery(info.pagesProjectsPage5, { page: 5, received: 7, perPage: 10 }), null)
	})
	it('walks cursor result_info from a real R2 list shape', () => {
		const info = JSON.parse(readFileSync(join(fixtures, 'result-info.json'), 'utf8'))
		assert.deepEqual(
			nextPageQuery(info.r2BucketsCursor, { page: 1, received: 20 }),
			{ cursor: info.r2BucketsCursor.cursor },
		)
		assert.equal(nextPageQuery(info.r2BucketsCursor, { page: 1, received: 0 }), null)
	})
	it('extracts items from array or single-array-property results', () => {
		assert.deepEqual(extractListItems([{ id: 1 }]), [{ id: 1 }])
		assert.deepEqual(extractListItems({ buckets: [{ name: 'a' }] }), [{ name: 'a' }])
		assert.deepEqual(extractListItems({ buckets: [{ name: 'a' }] }, 'buckets'), [{ name: 'a' }])
	})
})

describe('pages helpers', () => {
	it('validates project names and reads deployment fixtures', () => {
		assert.equal(assertPagesProjectName('mega-week3-plan'), 'mega-week3-plan')
		assert.throws(() => assertPagesProjectName('Bad Name'), /Invalid/)
		const live = JSON.parse(readFileSync(join(fixtures, 'pages-deployment-live.json'), 'utf8'))
		assert.deepEqual(deploymentState(live), { stage: 'deploy:success', live: true, failed: false })
		assert.equal(deploymentState({ id: 'x', latest_stage: { name: 'queued', status: 'idle' } }).live, false)
	})
	it('builds deployment form fields', () => {
		const form = buildDeploymentForm({
			manifest: { '/index.html': 'abc' },
			branch: 'main',
			message: 'hi',
			config: { _headers: '/*\n  X: 1\n', _redirects: '/* /index.html 200\n' },
		})
		assert.equal(form.manifest, '{"/index.html":"abc"}')
		assert.equal(form.branch, 'main')
		assert.deepEqual(form._headers, { filename: '_headers', content: '/*\n  X: 1\n' })
	})
})

describe('accounts metadata', () => {
	it('documents every alias with can/cannot and known account ids', () => {
		const list = accounts()
		assert.deepEqual(list.map((a) => a.account), ['default', 'kody', 'pages'])
		const pages = list.find((a) => a.account === 'pages')!
		assert.equal(pages.accountId, KCD_CLOUDFLARE_ACCOUNT_ID)
		assert.equal(pages.accountName, 'KCD')
		assert.ok(pages.can.some((c) => /Pages/.test(c)))
		assert.ok(pages.cannot.some((c) => /GET \/accounts/.test(c)))
		assert.equal(resolveKnownAccountId({ account: 'pages' }), KCD_CLOUDFLARE_ACCOUNT_ID)
		assert.equal(resolveKnownAccountId({ account: 'default' }), null)
	})
})