// web init / web check: builders (exact JSON), validators, live checks against a local server, init into a temp dir. import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ShipConfig } from '../cli/lib/config'; import { TEMPLATE_TEXT } from '../cli/lib/template-files'; import { aasaFor, assetLinksFor, checkLanding, fillLanding, HANDLE_ALL_URLS, normalizeFingerprint, planWeb, smartBannerTag, validateAasa, validateAssetLinks, webIdsOf, } from '../cli/lib/web'; import { checkSite } from '../cli/web'; const ROOT = join(import.meta.dir, '..'); const fp = Array.from({ length: 32 }, (_, i) => (i * 7 % 256).toString(16).toUpperCase().padStart(2, '0')).join(':'); const fp2 = Array.from({ length: 32 }, (_, i) => (255 - i).toString(16).toUpperCase().padStart(2, '0')).join(':'); const cfg: ShipConfig = { app: { name: 'Example & Co', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', termsUrl: 'https://example.com/terms', website: 'https://example.com', contactEmail: 'hi@example.com' }, languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }], apple: { bundleId: 'com.example.app', teamId: 'ABCDEFGHIJ', appId: '123456789' }, play: { packageName: 'com.example.app' }, web: { domain: 'links.example.com', paths: ['/invite/*'], exclude: ['/admin/*'], androidSha256: [fp], webcredentials: true }, }; const ids = webIdsOf(cfg); const problems = (f: { level: string; message: string }[]) => f.filter(x => x.level === 'problem').map(x => x.message); describe('builders', () => { test('apple-app-site-association: exact JSON, excludes first', () => { expect(aasaFor(cfg)).toEqual({ applinks: { details: [{ appIDs: ['ABCDEFGHIJ.com.example.app'], components: [{ '/': '/admin/*', exclude: true, comment: 'stays in the browser' }, { '/': '/invite/*' }] }] }, webcredentials: { apps: ['ABCDEFGHIJ.com.example.app'] }, }); expect(aasaFor({ ...cfg, web: undefined })).toEqual({ applinks: { details: [{ appIDs: ['ABCDEFGHIJ.com.example.app'], components: [{ '/': '/*' }] }] } }); expect(aasaFor({ ...cfg, apple: { bundleId: 'com.example.app' } })).toBeNull(); }); test('assetlinks.json: exact JSON, dynamic rules only with excludes', () => { expect(assetLinksFor({ ...cfg, web: { domain: 'links.example.com', androidSha256: [fp] } })).toEqual([ { relation: [HANDLE_ALL_URLS], target: { namespace: 'android_app', package_name: 'com.example.app', sha256_cert_fingerprints: [fp] } }, ]); expect(assetLinksFor(cfg)![0]!.relation_extensions).toEqual({ [HANDLE_ALL_URLS]: { dynamic_app_link_components: [{ '/': '/admin/*', exclude: true }, { '/': '/invite/*' }] } }); expect(assetLinksFor({ ...cfg, web: { domain: 'links.example.com' } })).toBeNull(); }); test('Smart App Banner tag and landing page', () => { expect(smartBannerTag(cfg)).toBe(''); expect(smartBannerTag(cfg, 'https://links.example.com/invite/1')).toBe(''); expect(smartBannerTag({ ...cfg, apple: { bundleId: 'com.example.app' } })).toBeNull(); const html = fillLanding(TEMPLATE_TEXT['web/landing.html']!, cfg); expect(html).toContain(''); expect(html).toContain('Example & Co'); expect(html).toContain('href="https://apps.apple.com/app/id123456789"'); expect(html).toContain('href="https://play.google.com/store/apps/details?id=com.example.app"'); expect(html).toContain('href="https://example.com/terms"'); expect(html).toContain('App Store is a trademark of Apple Inc.; Google Play is a trademark of Google LLC.'); expect(html).not.toMatch(/\{\{|/); expect(html).not.toMatch(/https?:\/\/(?!apps\.apple\.com|play\.google\.com|example\.com)/); const playOnly = fillLanding(TEMPLATE_TEXT['web/landing.html']!, { ...cfg, apple: undefined, app: { ...cfg.app, termsUrl: undefined } }); expect(playOnly).not.toContain('apple-itunes-app'); expect(playOnly).not.toContain('apps.apple.com'); expect(playOnly).not.toContain('Terms of Use'); expect(playOnly).toContain('Google Play'); }); test('fingerprints from the Play API: hex, colon hex, base64, base64url', () => { const hex = fp.replace(/:/g, ''); const b64 = Buffer.from(hex, 'hex').toString('base64'); expect(normalizeFingerprint(hex.toLowerCase())).toBe(fp); expect(normalizeFingerprint(fp.toLowerCase())).toBe(fp); expect(normalizeFingerprint(b64)).toBe(fp); expect(normalizeFingerprint(b64.replace(/=$/, '').replace(/\+/g, '-').replace(/\//g, '_'))).toBe(fp); expect(normalizeFingerprint('abcd')).toBeNull(); }); test('plan: missing inputs are skipped with the fix, not errors', () => { const p = planWeb({ ...cfg, apple: { bundleId: 'com.example.app' }, web: { domain: 'links.example.com' } }, { landing: false }); expect(p.files).toEqual([]); expect(p.skipped.join('\n')).toContain('apple.teamId'); expect(p.skipped.join('\n')).toContain('Play app signing'); expect(p.skipped.join('\n')).toContain('apple.appId'); const full = planWeb(cfg, { landing: true, template: TEMPLATE_TEXT['web/landing.html'], marks: { appstore: 'a', googleplay: 'g' } }); expect(full.files.map(f => f.path)).toEqual(['.well-known/apple-app-site-association', '.well-known/assetlinks.json', 'smart-app-banner.html', 'index.html', 'appstore.svg', 'googleplay.svg']); expect(full.skipped).toEqual([]); }); }); describe('validators', () => { const aasa = aasaFor(cfg)!; const links = assetLinksFor(cfg)!; test('good files pass', () => { expect(problems(validateAasa(aasa, ids))).toEqual([]); expect(problems(validateAssetLinks(links, ids))).toEqual([]); }); test('AASA: wrong team id, empty components, legacy format, missing webcredentials', () => { const wrong = aasaFor({ ...cfg, apple: { ...cfg.apple!, teamId: 'ZYXWVUTSRQ' } }); expect(problems(validateAasa(wrong, ids)).join()).toContain('does not list ABCDEFGHIJ.com.example.app'); expect(problems(validateAasa({ applinks: { details: [{ appIDs: ['ABCDEFGHIJ.com.example.app'], components: [] }] } }, ids)).join()).toContain('no URL opens the app'); expect(problems(validateAasa({ applinks: { details: [{ appIDs: ['ABCDEFGHIJ.com.example.app'], components: [{ '/': '/*', exclude: true }] }] } }, ids)).join()).toContain('every component is excluded'); const legacy = validateAasa({ applinks: { apps: [], details: [{ appID: 'ABCDEFGHIJ.com.example.app', paths: ['*'] }] } }, { teamId: 'ABCDEFGHIJ', bundleId: 'com.example.app' }); expect(problems(legacy)).toEqual([]); expect(legacy.some(x => x.level === 'info' && /legacy/.test(x.message))).toBe(true); expect(problems(validateAasa({ applinks: { details: [{ appIDs: ['ABCDEFGHIJ.com.example.app'], components: [{ '/': '/*' }] }] } }, ids)).join()).toContain('no "webcredentials" section'); expect(problems(validateAasa([], ids))).toEqual(['not a JSON object']); }); test('assetlinks: missing fingerprint, extra fingerprint, wrong relation, wrong package, object instead of list', () => { const extra = validateAssetLinks(assetLinksFor(cfg, [fp, fp2]), ids); expect(problems(extra)).toEqual([]); expect(extra.some(x => x.level === 'info' && /extra fingerprint/.test(x.message))).toBe(true); expect(problems(validateAssetLinks(assetLinksFor(cfg, [fp2]), ids)).join()).toContain('is missing'); const rel = [{ relation: ['delegate_permission/common.get_login_creds'], target: { namespace: 'android_app', package_name: 'com.example.app', sha256_cert_fingerprints: [fp] } }]; expect(problems(validateAssetLinks(rel, ids)).join()).toContain(`not ${HANDLE_ALL_URLS}`); expect(problems(validateAssetLinks(assetLinksFor({ ...cfg, play: { packageName: 'com.example.other' } }), ids)).join()).toContain('no android_app statement for com.example.app'); expect(problems(validateAssetLinks(links[0], ids)).join()).toContain('not a list of statements'); const lower = [{ relation: [HANDLE_ALL_URLS], target: { namespace: 'android_app', package_name: 'com.example.app', sha256_cert_fingerprints: [fp.toLowerCase()] } }]; expect(problems(validateAssetLinks(lower, ids)).join()).toContain('AB:CD'); }); test('landing: Smart App Banner app-id', () => { const page = (body: string) => ({ url: 'https://links.example.com/', status: 200, contentType: 'text/html', body, bytes: body.length }); expect(checkLanding(page(''), ids)[0]!.level).toBe('ok'); expect(checkLanding(page(''), ids)[0]!.level).toBe('problem'); expect(checkLanding(page(''), ids)[0]!.level).toBe('problem'); }); }); describe('web check against a local server', () => { type Route = { status?: number; type?: string; body?: string; location?: string }; let routes: Record = {}; let server: ReturnType; let base = ''; beforeAll(() => { server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(req) { const r = routes[new URL(req.url).pathname]; if (!r) return new Response('not found', { status: 404, headers: { 'content-type': 'text/html' } }); return new Response(r.body ?? '', { status: r.status ?? 200, headers: { ...(r.type ? { 'content-type': r.type } : {}), ...(r.location ? { location: r.location } : {}) } }); } }); base = `http://127.0.0.1:${server.port}`; }); afterAll(() => server.stop(true)); const good = (): Record => ({ '/.well-known/apple-app-site-association': { type: 'application/json', body: JSON.stringify(aasaFor(cfg)) }, '/.well-known/assetlinks.json': { type: 'application/json', body: JSON.stringify(assetLinksFor(cfg)) }, '/': { type: 'text/html', body: fillLanding(TEMPLATE_TEXT['web/landing.html']!, cfg) }, }); test('good site: no problems, legacy path is informational', async () => { routes = good(); const c = await checkSite(base, ids, { landing: true }); expect(c.filter(x => !x.ok)).toEqual([]); expect(c.find(x => x.id === 'aasa-legacy')!.level).toBe('info'); expect(c.find(x => x.id === 'landing')!.level).toBe('ok'); expect(c.every(x => typeof x.url === 'string' && typeof x.message === 'string')).toBe(true); }); test('redirect is a problem and is not followed', async () => { routes = { ...good(), '/.well-known/apple-app-site-association': { status: 301, location: 'https://www.links.example.com/.well-known/apple-app-site-association' } }; const c = await checkSite(base, ids); const bad = c.filter(x => x.id === 'aasa' && !x.ok); expect(bad).toHaveLength(1); expect(bad[0]!.message).toContain('HTTP 301 redirect to https://www.links.example.com'); }); test('text/plain content type and an HTML fallback page are problems', async () => { routes = { ...good(), '/.well-known/assetlinks.json': { type: 'text/plain', body: JSON.stringify(assetLinksFor(cfg)) }, '/.well-known/apple-app-site-association': { type: 'application/json', body: '' } }; const c = await checkSite(base, ids); expect(c.filter(x => !x.ok).map(x => `${x.id}: ${x.message}`)).toEqual([ 'aasa: the body is an HTML page, not JSON (a single-page-app fallback or a custom 404 page?)', 'assetlinks: content-type is "text/plain", not application/json', ]); }); test('missing files and wrong ids', async () => { routes = { '/.well-known/apple-app-site-association': { type: 'application/json', body: JSON.stringify(aasaFor({ ...cfg, apple: { ...cfg.apple!, teamId: 'ZYXWVUTSRQ' } })) } }; const c = await checkSite(base, ids); expect(c.filter(x => !x.ok).map(x => x.id)).toEqual(['aasa', 'aasa', 'assetlinks']); // appIDs and webcredentials.apps expect(c.find(x => x.id === 'assetlinks')!.message).toBe('HTTP 404'); }); }); describe('web init (CLI)', () => { const project = () => { const d = mkdtempSync(join(tmpdir(), 'sb-web-')); const c = { ...cfg, web: { ...cfg.web!, landing: true } }; writeFileSync(join(d, 'storebound.config.json'), JSON.stringify(c)); return d; }; const cli = (d: string, ...args: string[]) => Bun.spawnSync(['bun', join(ROOT, 'cli/index.ts'), 'web', 'init', ...args], { cwd: d, env: { ...process.env, NO_COLOR: '1' } }); test('--dry prints the files and writes nothing', () => { const d = project(); try { const r = cli(d, '--dry'); expect(r.exitCode).toBe(0); const out = r.stdout.toString(); expect(out).toContain('would write web/.well-known/apple-app-site-association'); expect(out).toContain('"ABCDEFGHIJ.com.example.app"'); expect(out).toContain(''); expect(readdirSync(d)).toEqual(['storebound.config.json']); } finally { rmSync(d, { recursive: true, force: true }); } }, 30_000); test('writes the files, then reports unchanged / updated', () => { const d = project(); try { const first = cli(d, '--json'); expect(first.exitCode).toBe(0); const j = JSON.parse(first.stdout.toString()); expect(j.files.map((f: { path: string; status: string }) => `${f.status} ${f.path}`)).toEqual([ 'new web/.well-known/apple-app-site-association', 'new web/.well-known/assetlinks.json', 'new web/smart-app-banner.html', 'new web/index.html', 'new web/appstore.svg', 'new web/googleplay.svg', ]); expect(j.skipped).toEqual([]); expect(JSON.parse(readFileSync(join(d, 'web/.well-known/assetlinks.json'), 'utf8'))).toEqual(assetLinksFor(cfg)); expect(readFileSync(join(d, 'web/appstore.svg'), 'utf8')).toBe(readFileSync(join(ROOT, 'templates/web/appstore.svg'), 'utf8')); writeFileSync(join(d, 'web/smart-app-banner.html'), 'old'); const second = JSON.parse(cli(d, '--json').stdout.toString()); expect(second.files.filter((f: { status: string }) => f.status !== 'unchanged').map((f: { path: string; status: string }) => `${f.status} ${f.path}`)).toEqual(['updated web/smart-app-banner.html']); mkdirSync(join(d, 'site')); expect(cli(d, '--out', 'site', '--json').exitCode).toBe(0); expect(existsSync(join(d, 'site/.well-known/apple-app-site-association'))).toBe(true); } finally { rmSync(d, { recursive: true, force: true }); } }, 30_000); });