From e9507d8884971161711a7bf65a3bf24cd17f6772 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 14:37:47 +0800 Subject: [PATCH] check blocks an apple app without app.copyright (App Store Connect requires it; never filled in automatically); validate reads the live version's copyright and says whether the store has one; round-trip test that the config writers (upsertBlock, upsertNestedString, removeKeyPath) keep unknown keys and comments; tests --- cli/check.ts | 2 + cli/lib/validate-rules.ts | 16 +++++++- cli/validate.ts | 3 +- tests/apple-name.test.ts | 2 +- tests/copyright-config-edit.test.ts | 59 +++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 tests/copyright-config-edit.test.ts diff --git a/cli/check.ts b/cli/check.ts index f94ed37..7e675c8 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -5,6 +5,7 @@ // storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors // --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems import { templateBlockers } from './lib/template-answers'; +import { COPYRIGHT_MISSING } from './lib/validate-rules'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources, typeIdOf } from './lib/app-privacy'; @@ -143,6 +144,7 @@ export async function run(ctx: Ctx): Promise { const { runScan } = await import('./lib/scan'); try { for (const n of (await runScan(ctx.loaded.root, { config: ctx.loaded.path })).report.notes) if (n.startsWith('scan.ignore[')) note(n); } catch (e) { note(`scan.ignore not checked: ${(e as Error).message}`); } } + if (config.apple && !config.app.copyright?.trim()) problems.push(COPYRIGHT_MISSING); if (config.dataSafety) tryDo('data safety', () => { const rows = dataSafetyRows(config.dataSafety!, config.accounts); // conditional required rows (e.g. the delete-account URL once accounts exist): Play answers 400 without them diff --git a/cli/lib/validate-rules.ts b/cli/lib/validate-rules.ts index 41d0c31..11a52f7 100644 --- a/cli/lib/validate-rules.ts +++ b/cli/lib/validate-rules.ts @@ -43,7 +43,9 @@ export function rendersFindings(store: 'apple' | 'play', r: RendersCheck | undef } export interface AppleSnapshot { - version: { versionString: string; state: string } | null; + version: { versionString: string; state: string; copyright?: string | null } | null; + /** app.copyright from the config (undefined: not set). */ + configCopyright?: string; build: { number: string; processingState: string; usesNonExemptEncryption: boolean | null; declarationState: string | null } | null; /** Locales the config expects (apple: entries of `languages`). */ expectedLocales: string[]; @@ -140,6 +142,17 @@ const ageOpen = (r: Record): { open: string[]; soft: string[] } const urlOk = (u: UrlCheck) => u.status !== null && u.status >= 200 && u.status < 300; const urlMsg = (u: UrlCheck) => (u.status === null ? `unreachable (${u.error ?? 'no answer'})` : `HTTP ${u.status}`); +export const COPYRIGHT_MISSING = "app.copyright is missing: App Store Connect requires it. Use your public brand (e.g. '2026 '); never a company name you keep private."; + +/** Copyright: App Store Connect refuses to submit a version without one; storebound never fills it in. */ +export function copyrightRules(s: Pick): Finding[] { + if (!s.version || s.version.copyright === undefined) return []; + const live = s.version.copyright?.trim(); + const cfg = s.configCopyright?.trim(); + if (live) return [{ store: 'apple', level: cfg ? 'ok' : 'warn', code: 'copyright', msg: cfg ? `copyright on the store: ${live}` : `copyright on the store: ${live}; ${COPYRIGHT_MISSING}`, fix: cfg ? undefined : 'set app.copyright in storebound.config.ts' }]; + return [{ store: 'apple', level: 'blocker', code: 'copyright', msg: `version ${s.version.versionString} has no copyright on the store${cfg ? ` (config has '${cfg}': storebound apple listing sends it)` : `; ${COPYRIGHT_MISSING}`}`, fix: cfg ? 'storebound apple listing --version x.y.z' : 'set app.copyright in storebound.config.ts, then storebound apple listing' }]; +} + export function appleRules(s: AppleSnapshot): Finding[] { const f: Finding[] = []; const add = (level: Level, code: string, msg: string, fix?: string) => f.push({ store: 'apple', level, code, msg, fix }); @@ -148,6 +161,7 @@ export function appleRules(s: AppleSnapshot): Finding[] { else if (EDITABLE.includes(s.version.state)) add('ok', 'version', `version ${s.version.versionString}: ${s.version.state}`); else if (IN_FLIGHT.includes(s.version.state)) add('warn', 'version', `version ${s.version.versionString} is ${s.version.state} (nothing to prepare)`); else add('warn', 'version', `version ${s.version.versionString}: ${s.version.state}`); + f.push(...copyrightRules(s)); if (!s.build) add('blocker', 'build', 'no build attached to the version', 'storebound apple listing --version x.y.z --build N (pick the build number you just uploaded)'); else { diff --git a/cli/validate.ts b/cli/validate.ts index 67253e0..39da507 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -103,7 +103,8 @@ export async function collectApple(ctx: Ctx): Promise { subscriptions, expectedSubscriptions: wantSubs.map(({ group, sub }) => ({ id: sub.id, group: group.name, locales: Object.keys(sub.listings).map(id => appleLocale.get(id)).filter((x): x is string => !!x) })), profile: await collectProfile(ctx.loaded), - version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState } : null, + version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState, copyright: v.attributes.copyright ?? null } : null, + configCopyright: config.app.copyright, build, locales, reviewDetail, ageRating, expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x), requiredDisplayTypes: requiredDisplayTypes(config.apple), diff --git a/tests/apple-name.test.ts b/tests/apple-name.test.ts index 66c2d3b..4f4e0b7 100644 --- a/tests/apple-name.test.ts +++ b/tests/apple-name.test.ts @@ -13,7 +13,7 @@ import { BASE, capture, jsonOut, project, restore } from './helpers/store-mock'; afterEach(restore); const NAMED: ShipConfig = { ...BASE, app: { ...BASE.app, name: 'STAR DASH' }, apple: { ...BASE.apple!, name: 'STAR DASH: Stone Age to 2100', primaryLocale: 'en-US' } }; -const PLAIN: ShipConfig = { ...BASE, app: { ...BASE.app, name: 'STAR DASH' }, apple: { ...BASE.apple!, primaryLocale: 'en-US' } }; +const PLAIN: ShipConfig = { ...BASE, app: { ...BASE.app, name: 'STAR DASH', copyright: '2026 Example Brand' }, apple: { ...BASE.apple!, primaryLocale: 'en-US' } }; const sec = (loc: string, name?: string) => `## ${loc}\n\n${name ? `### name\n\`\`\`\n${name}\n\`\`\`\n\n` : ''}### subtitle\n\`\`\`\nSub\n\`\`\`\n\n### promotionalText\n\`\`\`\nPromo\n\`\`\`\n\n### keywords\n\`\`\`\nrun,time\n\`\`\`\n\n### description\n\`\`\`\nText.\n\`\`\`\n\n`; describe('apple.name', () => { diff --git a/tests/copyright-config-edit.test.ts b/tests/copyright-config-edit.test.ts new file mode 100644 index 0000000..bd4574a --- /dev/null +++ b/tests/copyright-config-edit.test.ts @@ -0,0 +1,59 @@ +// app.copyright: check blocks when an apple app has none, validate reads the live version's copyright; and every +// config writer (config-edit.ts) edits storebound.config.ts in place, keeping unknown keys and comments +import { describe, expect, test } from 'bun:test'; +import { removeKeyPath, upsertBlock, upsertNestedString } from '../cli/lib/scan/config-edit'; +import { COPYRIGHT_MISSING, copyrightRules } from '../cli/lib/validate-rules'; +import { run as check } from '../cli/check'; +import { BASE, capture, project } from './helpers/store-mock'; + +describe('app.copyright', () => { + test('check reports it missing for an apple app, never fills it', async () => { + const r = await capture(() => check(project(BASE).ctx(['--json'], ['json']))); + expect(r.out + r.err).toContain(COPYRIGHT_MISSING); + const ok = await capture(() => check(project({ ...BASE, app: { ...BASE.app, copyright: '2026 Example Brand' } }).ctx(['--json'], ['json']))); + expect(ok.out + ok.err).not.toContain('app.copyright is missing'); + const playOnly = await capture(() => check(project({ ...BASE, apple: undefined }).ctx(['--json'], ['json']))); + expect(playOnly.out + playOnly.err).not.toContain('app.copyright is missing'); + }); + test('validate: says whether the store has one', () => { + const v = (copyright: string | null) => ({ versionString: '1.0', state: 'PREPARE_FOR_SUBMISSION', copyright }); + expect(copyrightRules({ version: v(null) })[0]).toMatchObject({ level: 'blocker', code: 'copyright' }); + expect(copyrightRules({ version: v(null) })[0]!.msg).toContain(COPYRIGHT_MISSING); + expect(copyrightRules({ version: v(null), configCopyright: '2026 B' })[0]!.msg).toContain("config has '2026 B'"); + expect(copyrightRules({ version: v('2026 B'), configCopyright: '2026 B' })[0]).toMatchObject({ level: 'ok', msg: 'copyright on the store: 2026 B' }); + expect(copyrightRules({ version: v('2026 B') })[0]!.level).toBe('warn'); + expect(copyrightRules({ version: null })).toEqual([]); + }); +}); + +describe('config writers round-trip', () => { + const SRC = `// my project notes: keep me +import { defineConfig } from 'storebound'; + +export default defineConfig({ + app: { + name: 'Example', // inline comment + customThing: { nested: [1, 2, 3] }, // unknown key + }, + /* block comment */ + futureKey: 'kept', + play: { packageName: 'com.example.app' }, + accounts: { enabled: true }, +}); +`; + test('upsertBlock, upsertNestedString and removeKeyPath keep everything else byte for byte', () => { + let t = upsertNestedString(SRC, 'c.ts', 'app', 'copyright', '2026 Example'); + t = upsertNestedString(t, 'c.ts', 'play', 'appId', '123'); + t = upsertBlock(t, 'c.ts', 'dataSafety', { collectsData: false }, 'note'); + for (const keep of ['// my project notes: keep me', '// inline comment', "customThing: { nested: [1, 2, 3] }, // unknown key", '/* block comment */', "futureKey: 'kept'"]) expect(t).toContain(keep); + expect(t).toContain("copyright: '2026 Example'"); + // writing the same value again changes nothing; removing what was added gets the original back + expect(upsertNestedString(t, 'c.ts', 'app', 'copyright', '2026 Example')).toBe(t); + const back = removeKeyPath(removeKeyPath(removeKeyPath(t, 'c.ts', ['dataSafety']), 'c.ts', ['play', 'appId']), 'c.ts', ['app', 'copyright']); + // a removed last key may leave a trailing comma behind (valid TS) + expect(back.replace(/,(\s*})/g, '$1').replace(/\s+/g, ' ')).toBe(SRC.replace(/,(\s*})/g, '$1').replace(/\s+/g, ' ')); + // json configs keep unknown keys too + const j = JSON.parse(upsertNestedString(JSON.stringify({ app: { name: 'x', extra: 1 }, other: [1] }), 'c.json', 'app', 'copyright', '2026 X')); + expect(j).toEqual({ app: { name: 'x', extra: 1, copyright: '2026 X' }, other: [1] }); + }); +});