215 lines
14 KiB
TypeScript
215 lines
14 KiB
TypeScript
// 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('<meta name="apple-itunes-app" content="app-id=123456789">');
|
|
expect(smartBannerTag(cfg, 'https://links.example.com/invite/1')).toBe('<meta name="apple-itunes-app" content="app-id=123456789, app-argument=https://links.example.com/invite/1">');
|
|
expect(smartBannerTag({ ...cfg, apple: { bundleId: 'com.example.app' } })).toBeNull();
|
|
const html = fillLanding(TEMPLATE_TEXT['web/landing.html']!, cfg);
|
|
expect(html).toContain('<meta name="apple-itunes-app" content="app-id=123456789">');
|
|
expect(html).toContain('<title>Example & Co</title>');
|
|
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(/\{\{|<!--\/?(apple|play|terms)-->/);
|
|
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</a>');
|
|
});
|
|
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('<head><meta name="apple-itunes-app" content="app-id=123456789"></head>'), ids)[0]!.level).toBe('ok');
|
|
expect(checkLanding(page('<head><meta name="apple-itunes-app" content="app-id=1"></head>'), ids)[0]!.level).toBe('problem');
|
|
expect(checkLanding(page('<head></head>'), 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<string, Route> = {};
|
|
let server: ReturnType<typeof Bun.serve>;
|
|
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('<html>not found</html>', { 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<string, Route> => ({
|
|
'/.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: '<!doctype html><html></html>' } };
|
|
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('<meta name="apple-itunes-app" content="app-id=123456789">');
|
|
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);
|
|
});
|