play data-safety: write the conditional required rows (PSL_ACCOUNT_DELETION_URL from accounts.deletionUrl, PSL_SUPPORT_DATA_DELETION_BY_USER from dataSafety.partialDeletion, PSL_DATA_DELETION_URL); a rule table checks every conditional row in check, declarations and --dry before Play answers 400
This commit is contained in:
parent
c9a7f095c4
commit
97ab76e9c8
14 changed files with 295 additions and 24 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -2,6 +2,19 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Fix (`play data-safety`): apps with accounts got Play 400 "Invalid safety labels declaration: Response missing for
|
||||
PSL_ACCOUNT_DELETION_URL". When `dataSafety.accountCreation` is not `PSL_ACM_NONE`, the CSV now carries
|
||||
`PSL_ACCOUNT_DELETION_URL` = `accounts.deletionUrl` and `PSL_SUPPORT_DATA_DELETION_BY_USER` = the new
|
||||
`dataSafety.partialDeletion` (delete some or all data without deleting the account; ask the owner), plus
|
||||
`PSL_DATA_DELETION_URL` = new `dataSafety.partialDeletionUrl` (default `accounts.deletionUrl`) when that is true.
|
||||
- Required-row rules in one table (`DATA_SAFETY_RULES`, per data type `typeRules`, `dataSafetyIssues`): account
|
||||
creation -> deletion rows, a selected data type -> collected / shared, ephemeral, required / optional, purposes,
|
||||
`PSL_HAS_OUTSIDE_APP_ACCOUNTS` refused with sign-up methods. `storebound check`, `project.declarations` and
|
||||
`play data-safety --dry` say `missing: <id> (<when>; set <config field>)`; the real run stops before the POST.
|
||||
- A `dataSafety.extra` row with a generated question id now replaces that question instead of adding a second one.
|
||||
- Sources: support.google.com/googleplay/android-developer/answer/10787469 ,
|
||||
support.google.com/googleplay/android-developer/answer/13327111 . Pitfall 21.
|
||||
|
||||
- Agent-first console mode. `storebound console plan <form> [--json]` (read-only, offline; `--lookup` asks the App
|
||||
Store Connect API for the app id) gives an AI agent everything to fill a console form with its own browser tool:
|
||||
the start URL (deep links from apple.appId, play.developerId, the new play.appId and the remembered ids), ordered
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { draftLocales } from './lib/aso-intel';
|
|||
import { appleName, playCodes } from './lib/config';
|
||||
import { APPLE_IAP_LIMITS, appleNameIssues, len, parseAppStoreListing, parsePlayListing } from './lib/copy';
|
||||
import type { Ctx } from './lib/ctx';
|
||||
import { dataSafetyRows } from './lib/data-safety';
|
||||
import { dataSafetyIssues, dataSafetyRows } from './lib/data-safety';
|
||||
import { reviewDemoAccount } from './lib/credentials';
|
||||
import { log, printJson, warn } from './lib/log';
|
||||
import { profileIssues } from './lib/profile';
|
||||
|
|
@ -96,7 +96,12 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
log('check', `app privacy: ${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project`);
|
||||
}
|
||||
}
|
||||
if (config.dataSafety) tryDo('data safety', () => `${dataSafetyRows(config.dataSafety!).length - 1} CSV rows`);
|
||||
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
|
||||
for (const i of dataSafetyIssues(rows)) problems.push(`data safety: ${i.msg}`);
|
||||
return `${rows.length - 1} CSV rows`;
|
||||
});
|
||||
for (const p of config.products ?? []) for (const [lang, l] of Object.entries(p.listings)) {
|
||||
if (config.apple && len(l.description) > APPLE_IAP_LIMITS.description) problems.push(`product ${p.id} ${lang}: description ${len(l.description)} > ${APPLE_IAP_LIMITS.description} (App Store limit)`);
|
||||
if (len(l.description) > 200) problems.push(`product ${p.id} ${lang}: description ${len(l.description)} > 200 (Play limit)`);
|
||||
|
|
|
|||
|
|
@ -85,13 +85,23 @@ export interface DataSafety {
|
|||
deletionRequest: boolean;
|
||||
/** Response ids for PSL_SUPPORTED_ACCOUNT_CREATION_METHODS. Default ["PSL_ACM_NONE"] (no accounts). */
|
||||
accountCreation?: string[];
|
||||
/**
|
||||
* Play "Do you provide a way for users to request that some or all of their data is deleted, without requiring them
|
||||
* to delete their account?" (PSL_SUPPORT_DATA_DELETION_BY_USER). Play requires it once accountCreation is not
|
||||
* PSL_ACM_NONE. Only the owner knows the answer: leave it out and `check` / `play data-safety --dry` say it is missing.
|
||||
*/
|
||||
partialDeletion?: boolean;
|
||||
/** Web page for that data-only deletion (PSL_DATA_DELETION_URL, required when partialDeletion is true). Default:
|
||||
* accounts.deletionUrl, when that page also lets people delete data without deleting the account. */
|
||||
partialDeletionUrl?: string;
|
||||
/** Play data type id (e.g. "PSL_DEVICE_ID") -> usage. Types not listed are "not collected". */
|
||||
data: Record<string, DataUsage>;
|
||||
/**
|
||||
* Extra CSV rows for questions storebound does not model, appended as they are. Google does not publish every
|
||||
* question id (e.g. the delete-account URL asked when accountCreation is not PSL_ACM_NONE): export the form from
|
||||
* Play Console (App content > Data safety > Export to CSV), copy the exact ids from it, e.g.
|
||||
* { question: '<id from the export>', value: 'https://example.com/delete-account' }.
|
||||
* Extra CSV rows for questions storebound does not model, appended as they are; a row with the same question id as
|
||||
* a generated row replaces that generated question (every response row of it). Google does not publish every
|
||||
* question id: export the form from Play Console (App content > Data safety > Export to CSV), copy the exact ids
|
||||
* from it, e.g. { question: '<id from the export>', value: '...' }. The delete-account URL is generated from
|
||||
* accounts.deletionUrl (PSL_ACCOUNT_DELETION_URL).
|
||||
*/
|
||||
extra?: { question: string; response?: string; value: string; label?: string }[];
|
||||
}
|
||||
|
|
@ -874,6 +884,8 @@ export function validateConfig(c: unknown): string[] {
|
|||
if (!u.collect?.length) e.push(`dataSafety.data.${t}: at least one collection purpose`);
|
||||
if (u.shared && !u.share?.length) e.push(`dataSafety.data.${t}: shared, so it needs sharing purposes`);
|
||||
}
|
||||
if (cfg.dataSafety.partialDeletion !== undefined && typeof cfg.dataSafety.partialDeletion !== 'boolean') e.push('dataSafety.partialDeletion: true | false');
|
||||
if (cfg.dataSafety.partialDeletionUrl !== undefined && !URL_RE.test(cfg.dataSafety.partialDeletionUrl)) e.push('dataSafety.partialDeletionUrl: must be an https URL');
|
||||
for (const [i, x] of (cfg.dataSafety.extra ?? []).entries()) if (!/^[A-Z][A-Z0-9_:]+$/.test(x?.question ?? '') || typeof x.value !== 'string')
|
||||
e.push(`dataSafety.extra[${i}]: { question: "<PSL_... id from a Play Console export>", value: "..." }`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// Google Play Data safety form as Play's CSV import format (the same layout Play Console exports from the Data safety
|
||||
// page: "Export to CSV"). One row per question / choice. Importing replaces every earlier answer. Pure.
|
||||
// Play names a missing answer "Response missing for <ID>" and a wrong choice "Invalid response ID <ID>".
|
||||
import type { DataSafety, PlayPurpose } from './config';
|
||||
// Some questions are required only after another answer (DATA_SAFETY_RULES below); dataSafetyIssues finds a missing
|
||||
// one offline, so a 400 like "Response missing for PSL_ACCOUNT_DELETION_URL" shows up in --dry and `check`.
|
||||
import type { AccountsConfig, DataSafety, PlayPurpose } from './config';
|
||||
|
||||
export const PURPOSE_IDS: Record<PlayPurpose, string> = {
|
||||
functionality: 'PSL_APP_FUNCTIONALITY',
|
||||
|
|
@ -37,7 +39,10 @@ const HEADER = ['Question ID (machine readable)', 'Response ID (machine readable
|
|||
|
||||
export function csvCell(s: string): string { return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }
|
||||
|
||||
export function dataSafetyRows(ds: DataSafety): string[][] {
|
||||
/** The accounts part of the config the form needs (the delete-account URL). */
|
||||
export type DataSafetyAccounts = Pick<AccountsConfig, 'deletionUrl'> | undefined;
|
||||
|
||||
export function dataSafetyRows(ds: DataSafety, accounts?: DataSafetyAccounts): string[][] {
|
||||
for (const t of Object.keys(ds.data)) if (!ALL_TYPES.has(t)) throw new Error(`dataSafety: unknown data type ${t}`);
|
||||
const b = (v: boolean) => (v ? 'TRUE' : 'FALSE');
|
||||
const pick = (v: boolean) => (v ? 'TRUE' : '');
|
||||
|
|
@ -46,8 +51,16 @@ export function dataSafetyRows(ds: DataSafety): string[][] {
|
|||
rows.push(['PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', '', b(ds.encryptedInTransit), 'MAYBE_REQUIRED', 'Is all of the user data collected by your app encrypted in transit?']);
|
||||
rows.push(['PSL_DATA_COLLECTION_USER_REQUEST_DELETE', '', b(ds.deletionRequest), 'MAYBE_REQUIRED', 'Do you provide a way for users to request that their data is deleted?']);
|
||||
// Newer form question that Google's older sample CSV does not have; the import fails without it.
|
||||
for (const id of ds.accountCreation ?? ['PSL_ACM_NONE'])
|
||||
rows.push(['PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', id, 'TRUE', 'MULTIPLE_CHOICE', `Account creation / ${id}`]);
|
||||
const acm = ds.accountCreation ?? ['PSL_ACM_NONE'];
|
||||
for (const id of acm) rows.push(['PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', id, 'TRUE', 'MULTIPLE_CHOICE', `Account creation / ${id}`]);
|
||||
// Data deletion questions Play asks once the app lets people create an account (Play Console Help 13327111).
|
||||
// An empty value is a missing answer: dataSafetyIssues names it with the config field to set.
|
||||
if (!acm.includes('PSL_ACM_NONE')) {
|
||||
rows.push(['PSL_ACCOUNT_DELETION_URL', '', accounts?.deletionUrl ?? '', 'MAYBE_REQUIRED', 'Delete account URL']);
|
||||
rows.push(['PSL_SUPPORT_DATA_DELETION_BY_USER', '', ds.partialDeletion === undefined ? '' : b(ds.partialDeletion), 'MAYBE_REQUIRED',
|
||||
'Do you provide a way for users to request that some or all of their data is deleted, without requiring them to delete their account?']);
|
||||
if (ds.partialDeletion) rows.push(['PSL_DATA_DELETION_URL', '', ds.partialDeletionUrl ?? accounts?.deletionUrl ?? '', 'MAYBE_REQUIRED', 'Delete data URL']);
|
||||
}
|
||||
for (const [cat, types] of CATEGORIES) for (const t of types) rows.push([cat, t, pick(!!ds.data[t]), 'MULTIPLE_CHOICE', `${cat} / ${t}`]);
|
||||
for (const [, types] of CATEGORIES) for (const t of types) {
|
||||
const u = ds.data[t];
|
||||
|
|
@ -63,8 +76,114 @@ export function dataSafetyRows(ds: DataSafety): string[][] {
|
|||
for (const p of PURPOSES) rows.push([q('DATA_USAGE_COLLECTION_PURPOSE'), p, pick(collect.has(p)), 'MULTIPLE_CHOICE', label + ' / Collection purpose']);
|
||||
for (const p of PURPOSES) rows.push([q('DATA_USAGE_SHARING_PURPOSE'), p, pick(share.has(p)), 'MULTIPLE_CHOICE', label + ' / Sharing purpose']);
|
||||
}
|
||||
for (const x of ds.extra ?? []) rows.push([x.question, x.response ?? '', x.value, x.response ? 'MULTIPLE_CHOICE' : 'MAYBE_REQUIRED', x.label ?? x.question]);
|
||||
return rows;
|
||||
// an extra row for a question storebound generates replaces that question (all its response rows)
|
||||
const replaced = new Set((ds.extra ?? []).map(x => x.question));
|
||||
const out = rows.filter((r, i) => i === 0 || !replaced.has(r[0]!));
|
||||
for (const x of ds.extra ?? []) out.push([x.question, x.response ?? '', x.value, x.response ? 'MULTIPLE_CHOICE' : 'MAYBE_REQUIRED', x.label ?? x.question]);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const dataSafetyCsv = (ds: DataSafety): string => dataSafetyRows(ds).map(r => r.map(csvCell).join(',')).join('\n') + '\n';
|
||||
export const dataSafetyCsv = (ds: DataSafety, accounts?: DataSafetyAccounts): string => dataSafetyRows(ds, accounts).map(r => r.map(csvCell).join(',')).join('\n') + '\n';
|
||||
|
||||
// ---- required-row rules ----
|
||||
|
||||
/** Answers of a CSV, read back from its rows (the header row is skipped). */
|
||||
export class DataSafetyAnswers {
|
||||
private readonly values = new Map<string, string>();
|
||||
private readonly picks = new Map<string, string[]>();
|
||||
readonly questions = new Set<string>();
|
||||
constructor(rows: string[][]) {
|
||||
for (const r of rows) {
|
||||
const q = r[0] ?? '', resp = r[1] ?? '', v = (r[2] ?? '').trim();
|
||||
if (!q || q === HEADER[0]) continue;
|
||||
this.questions.add(q);
|
||||
if (!resp) this.values.set(q, v);
|
||||
else if (v.toUpperCase() === 'TRUE') this.picks.set(q, [...(this.picks.get(q) ?? []), resp]);
|
||||
}
|
||||
}
|
||||
/** The value of a free-text / TRUE-FALSE question ('' when missing or empty). */
|
||||
value(q: string): string { return this.values.get(q) ?? ''; }
|
||||
/** Response ids of a choice question marked TRUE. */
|
||||
chosen(q: string): string[] { return this.picks.get(q) ?? []; }
|
||||
answered(q: string): boolean { return this.questions.has(q); }
|
||||
/** Account creation answered with a method other than "none". */
|
||||
get accounts(): boolean { const c = this.chosen('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS'); return c.length > 0 && !c.includes('PSL_ACM_NONE'); }
|
||||
get collects(): boolean { return this.value('PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA').toUpperCase() === 'TRUE'; }
|
||||
}
|
||||
|
||||
export interface DataSafetyRule {
|
||||
question: string;
|
||||
/** When Play requires the answer (for need 'absent': when Play refuses the question). */
|
||||
when: (a: DataSafetyAnswers) => boolean;
|
||||
/** The condition in words, for messages and docs. */
|
||||
condition: string;
|
||||
need: 'bool' | 'url' | 'choice' | 'one-choice' | 'absent';
|
||||
/** What to set in storebound.config.ts. */
|
||||
fix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form-level rules: question ids Play requires (or refuses) depending on other answers. Sources: Play Console Help
|
||||
* 10787469 (Data safety form, CSV import / export) and 13327111 (account deletion: apps with account creation give a
|
||||
* delete-account web link; data-only deletion is a separate question), plus Play's own 400 answers to imports
|
||||
* ("Response missing for PSL_ACCOUNT_DELETION_URL", "Response missing for PSL_DATA_DELETION_URL",
|
||||
* "You cannot answer PSL_HAS_OUTSIDE_APP_ACCOUNTS").
|
||||
*/
|
||||
export const DATA_SAFETY_RULES: DataSafetyRule[] = [
|
||||
{ question: 'PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA', when: () => true, condition: 'always', need: 'bool', fix: 'dataSafety.collectsData' },
|
||||
{ question: 'PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', when: a => a.collects, condition: 'the app collects data', need: 'bool', fix: 'dataSafety.encryptedInTransit' },
|
||||
{ question: 'PSL_DATA_COLLECTION_USER_REQUEST_DELETE', when: a => a.collects, condition: 'the app collects data', need: 'bool', fix: 'dataSafety.deletionRequest' },
|
||||
{ question: 'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', when: () => true, condition: 'always', need: 'choice', fix: "dataSafety.accountCreation (['PSL_ACM_NONE'] when there are no accounts)" },
|
||||
{ question: 'PSL_ACCOUNT_DELETION_URL', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE', need: 'url', fix: 'accounts.deletionUrl, a public https page to request account + data deletion (account-deletion skill)' },
|
||||
{ question: 'PSL_SUPPORT_DATA_DELETION_BY_USER', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE', need: 'bool', fix: 'dataSafety.partialDeletion: true | false, whether people can delete some or all data without deleting the account (ask the owner)' },
|
||||
{ question: 'PSL_DATA_DELETION_URL', when: a => a.value('PSL_SUPPORT_DATA_DELETION_BY_USER').toUpperCase() === 'TRUE', condition: 'PSL_SUPPORT_DATA_DELETION_BY_USER is TRUE', need: 'url', fix: 'dataSafety.partialDeletionUrl (default accounts.deletionUrl)' },
|
||||
{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', when: a => a.accounts, condition: 'account creation is not PSL_ACM_NONE; Play accepts it only with PSL_ACM_NONE', need: 'absent', fix: 'remove it from dataSafety.extra' },
|
||||
];
|
||||
|
||||
const usage = (t: string, s: string) => `PSL_DATA_USAGE_RESPONSES:${t}:${s}`;
|
||||
|
||||
/** Rules for one data type, derived from the form's structure (each type of CATEGORIES has the same usage block). */
|
||||
export function typeRules(t: string): DataSafetyRule[] {
|
||||
const how = (a: DataSafetyAnswers) => a.chosen(usage(t, 'PSL_DATA_USAGE_COLLECTION_AND_SHARING'));
|
||||
const collected = (a: DataSafetyAnswers) => how(a).includes('PSL_DATA_USAGE_ONLY_COLLECTED');
|
||||
const shared = (a: DataSafetyAnswers) => how(a).includes('PSL_DATA_USAGE_ONLY_SHARED');
|
||||
const selected = (a: DataSafetyAnswers) => CATEGORIES.some(([c]) => a.chosen(c).includes(t));
|
||||
const fix = `dataSafety.data.${t}`;
|
||||
return [
|
||||
{ question: usage(t, 'PSL_DATA_USAGE_COLLECTION_AND_SHARING'), when: selected, condition: `${t} is selected`, need: 'choice', fix: `${fix} (collected and / or shared)` },
|
||||
{ question: usage(t, 'PSL_DATA_USAGE_EPHEMERAL'), when: collected, condition: `${t} is collected`, need: 'bool', fix: `${fix}.ephemeral` },
|
||||
{ question: usage(t, 'DATA_USAGE_USER_CONTROL'), when: collected, condition: `${t} is collected`, need: 'one-choice', fix: `${fix}.optional` },
|
||||
{ question: usage(t, 'DATA_USAGE_COLLECTION_PURPOSE'), when: collected, condition: `${t} is collected`, need: 'choice', fix: `${fix}.collect` },
|
||||
{ question: usage(t, 'DATA_USAGE_SHARING_PURPOSE'), when: shared, condition: `${t} is shared`, need: 'choice', fix: `${fix}.share` },
|
||||
];
|
||||
}
|
||||
|
||||
export interface DataSafetyIssue { question: string; msg: string }
|
||||
|
||||
/** Required-row check of a Data safety CSV (rows as dataSafetyRows returns them, or a parsed Play export). Pure. */
|
||||
export function dataSafetyIssues(rows: string[][]): DataSafetyIssue[] {
|
||||
const a = new DataSafetyAnswers(rows);
|
||||
const out: DataSafetyIssue[] = [];
|
||||
for (const r of [...DATA_SAFETY_RULES, ...[...ALL_TYPES].flatMap(typeRules)]) {
|
||||
if (!r.when(a)) continue;
|
||||
const v = a.value(r.question), c = a.chosen(r.question);
|
||||
const say = (what: string) => out.push({ question: r.question, msg: `${what}: ${r.question} (${r.condition}; set ${r.fix})` });
|
||||
if (r.need === 'absent') { if (a.answered(r.question)) say('not allowed'); }
|
||||
else if (r.need === 'bool') { if (!/^(TRUE|FALSE)$/i.test(v)) say(v ? `not TRUE / FALSE ("${v}")` : 'missing'); }
|
||||
else if (r.need === 'url') { if (!/^https:\/\/\S+$/.test(v)) say(v ? `not an https URL ("${v}")` : 'missing'); }
|
||||
else if (r.need === 'choice') { if (!c.length) say('missing'); }
|
||||
else if (c.length !== 1) say(c.length ? `${c.length} answers to a single choice` : 'missing');
|
||||
}
|
||||
const acm = a.chosen('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS');
|
||||
if (acm.includes('PSL_ACM_NONE') && acm.length > 1)
|
||||
out.push({ question: 'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', msg: 'PSL_ACM_NONE together with sign-up methods: PSL_SUPPORTED_ACCOUNT_CREATION_METHODS (set dataSafety.accountCreation to PSL_ACM_NONE or to the methods)' });
|
||||
if (a.collects && !CATEGORIES.some(([cat]) => a.chosen(cat).length))
|
||||
out.push({ question: 'PSL_DATA_TYPES_*', msg: 'missing: a data type (collectsData is true; list the types in dataSafety.data or set collectsData: false)' });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The issues as one error message, or null. */
|
||||
export function dataSafetyProblem(rows: string[][]): string | null {
|
||||
const i = dataSafetyIssues(rows);
|
||||
return i.length ? `Data safety CSV is not complete (Play would answer 400):\n${i.map(x => ` - ${x.msg}`).join('\n')}` : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
// config does not prove is an "ask the owner" item, never a guess (AGENTS.md rule 5). Pure.
|
||||
import { parseCsv } from './csv';
|
||||
import { type AccountSignUp, type DataSafety, type PlayPurpose, profileOf, type ShipConfig } from './config';
|
||||
import { CATEGORIES, dataSafetyCsv } from './data-safety';
|
||||
import { CATEGORIES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from './data-safety';
|
||||
import type { DeclarationsResult, DeclAsk } from './desktop-protocol';
|
||||
import { NOTES_MAX_BYTES, reviewNotes } from './review-details';
|
||||
|
||||
|
|
@ -121,7 +121,10 @@ export function declarations(config: ShipConfig, inp: DeclarationInputs): Declar
|
|||
if (play && !ds) ask.push({ area: 'dataSafety', id: 'ds-missing', text: 'The config has no dataSafety answers, so storebound cannot fill the Play Data safety form.', fix: 'Answer what the app and its SDKs collect (store-declarations skill), then add dataSafety to storebound.config.ts.' });
|
||||
if (ds) {
|
||||
let csv = '';
|
||||
try { csv = dataSafetyCsv(ds); } catch (e) { ask.push({ area: 'dataSafety', id: 'ds-invalid', text: (e as Error).message, fix: 'Use the Play data type ids (PSL_...) in dataSafety.data.' }); }
|
||||
try { csv = dataSafetyCsv(ds, config.accounts); } catch (e) { ask.push({ area: 'dataSafety', id: 'ds-invalid', text: (e as Error).message, fix: 'Use the Play data type ids (PSL_...) in dataSafety.data.' }); }
|
||||
// conditional required rows Play would refuse the import without (delete-account URL, data-only deletion, ...)
|
||||
if (csv) for (const i of dataSafetyIssues(dataSafetyRows(ds, config.accounts)).filter(x => x.question !== 'PSL_DATA_TYPES_*')) // ds-no-types says it
|
||||
ask.push({ area: 'dataSafety', id: `ds-row-${i.question}`, text: i.msg, fix: i.msg.replace(/^.*; set /, 'Set ').replace(/\)$/, '.') });
|
||||
const types = Object.entries(ds.data).map(([id, u]) => ({ id, category: categoryOf(id), shared: !!u.shared, ephemeral: !!u.ephemeral, optional: !!u.optional, collect: [...u.collect], share: [...(u.shared ? u.share ?? [] : [])] }));
|
||||
dataSafety = {
|
||||
collectsData: ds.collectsData, encryptedInTransit: ds.encryptedInTransit, deletionRequest: ds.deletionRequest, accountCreation: ds.accountCreation ?? ['PSL_ACM_NONE'],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Play Data safety form through applications.dataSafety (CSV import; replaces every earlier answer).
|
||||
// storebound play data-safety [--csv out.csv] [--dry] [--force]
|
||||
// Answers come from `dataSafety` in storebound.config.ts. --dry only writes / prints the CSV.
|
||||
// Answers come from `dataSafety` (and accounts.deletionUrl) in storebound.config.ts. --dry only writes / prints the CSV.
|
||||
// Both --dry and a real run stop on a missing required row (dataSafetyIssues) before anything is sent.
|
||||
// Play cannot read the form back, so a run compares with what this project uploaded last: the sha256 of that CSV
|
||||
// (per package) is kept in .storebound/data-safety.sha256 (gitignored). The same CSV is not sent again; --force sends it
|
||||
// anyway (e.g. after the form was changed in Play Console by hand).
|
||||
|
|
@ -8,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|||
import { join } from 'node:path';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { need } from '../lib/ctx';
|
||||
import { dataSafetyCsv } from '../lib/data-safety';
|
||||
import { dataSafetyCsv, dataSafetyProblem, dataSafetyRows } from '../lib/data-safety';
|
||||
import { DATA_SAFETY_UPLOADED } from '../lib/declarations';
|
||||
import { done, log } from '../lib/log';
|
||||
import { playClient } from './client';
|
||||
|
|
@ -36,7 +37,9 @@ function rememberUpload(root: string, pkg: string, sha256: string): void {
|
|||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root, resolve } = ctx.loaded;
|
||||
const csv = dataSafetyCsv(need(config.dataSafety, 'dataSafety'));
|
||||
const ds = need(config.dataSafety, 'dataSafety');
|
||||
const csv = dataSafetyCsv(ds, config.accounts);
|
||||
const problem = dataSafetyProblem(dataSafetyRows(ds, config.accounts));
|
||||
const out = ctx.args.get('csv');
|
||||
if (out) { writeFileSync(resolve(out), csv); log('play', `wrote ${out}`); }
|
||||
const types = Object.keys(config.dataSafety!.data);
|
||||
|
|
@ -47,9 +50,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const same = last?.sha256 === hash;
|
||||
if (ctx.args.has('dry')) {
|
||||
if (!out) process.stdout.write(csv);
|
||||
if (problem) throw new Error(problem);
|
||||
log('play', same ? `the same CSV was uploaded ${last!.at.slice(0, 16).replace('T', ' ')} UTC: a real run sends nothing (--force sends it again)` : 'a real run uploads it (it differs from the last upload, or none is recorded)');
|
||||
return;
|
||||
}
|
||||
if (problem) throw new Error(problem);
|
||||
if (same && !ctx.args.has('force')) {
|
||||
done('play', `data safety unchanged since the upload of ${last!.at.slice(0, 16).replace('T', ' ')} UTC: nothing sent (--force sends it again, e.g. after a change in Play Console)`);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 21 | 2026-10 | Play Data safety: conditional required rows. `play data-safety` for a game with username accounts (`accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH']`) got 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL": once account creation is not `PSL_ACM_NONE`, Play requires the delete-account URL and the data-only deletion question (`PSL_SUPPORT_DATA_DELETION_BY_USER`, and `PSL_DATA_DELETION_URL` when it is yes), and refuses `PSL_HAS_OUTSIDE_APP_ACCOUNTS`; storebound wrote none of them. | `dataSafetyRows` writes them from `accounts.deletionUrl` and `dataSafety.partialDeletion` / `partialDeletionUrl`; `DATA_SAFETY_RULES` / `typeRules` / `dataSafetyIssues` in `cli/lib/data-safety.ts` check every conditional row in `check`, `project.declarations` and `play data-safety` (dry and real, before the POST); `tests/pure.test.ts` (conditional required rows), `tests/idempotency.test.ts`, `tests/app-privacy.test.ts`. |
|
||||
| 20 | 2026-10 | Hours of selector-fix cycles of the scripted console engine (`console fill`): the real console kept differing from the model (App Privacy "Set Up" as plain text, purpose checkboxes without an accessible name, two information steps in a type dialog, Play's material radios that ignore the native input). | Agent-first console mode: `console plan <form>` gives an AI agent the start URL, steps, answer sheet with the console's labels, never-press list and verified UI facts; the agent drives the page with its own browser tool and stops before the final button; `console verify <form>` checks the result (App Privacy summaries from the page text, Play Create app from input states, API for the App Store record and age rating). The scripted fill stays a fast path (and now handles the verified facts). MCP: `console_plan`, `console_verify` (read-only). `tests/console-plan.test.ts`, `tests/console-flow.test.ts`. |
|
||||
| 19 | 2026-10 | `play images --dry` aborted with "en-US: no sevenInchScreenshots ... (render first)" on a phone-only project: Play's 7" and 10" tablet screenshots are optional (required: 512 icon, 1024x500 feature graphic, at least 2 screenshots; Play Console Help answer 9866151), but every image type was treated as required. | `play images` uploads what exists (icon, feature graphic, phone screenshots, tablet / TV / Wear only when rendered), prints an info line for missing optional sets, never deletes what Play has for them without `--prune`; `play.tablets` (default: tablet renders exist) switches tablets for `shots render` / `shots check` / `shots gallery` / `validate` / `play images`, and `true` with missing renders is an error (`cli/play/images.ts` `langImages`, `resolvedTablets` in `cli/shots/report.ts`, `tests/play-post-reads.test.ts`). |
|
||||
| 18 | 2026-10 | `console fill play-app-record` with a copy of the owner's Chrome cookies reached the Play account chooser once, then Google answered `/v3/signin/rejected` and the chooser: Google session cookies are device-bound (Chrome's Device Bound Session Credentials) and Google signs out sessions whose cookies look stolen, which can also sign the owner out of their real Chrome. | Never copy Google cookies by default: storebound's own profile is the default (the owner signs in once by hand), started by storebound as a normal Chrome (no `--enable-automation`, `navigator.webdriver` false) and attached over CDP; `--use-chrome-session google\|apple` is an explicit opt-in that warns; `/signin/rejected` is said plainly (`cli/console/session.ts`, `tests/console-session.test.ts`). Confirmed 2026-10-12: the earlier default copy DID sign the owner out of Play Console in their real Chrome. |
|
||||
|
|
|
|||
|
|
@ -37,10 +37,13 @@ deletion requirements" (https://support.google.com/googleplay/android-developer/
|
|||
authentication" (`PSL_ACM_USER_ID_OTHER_AUTH`) with User ID as the only account data; `username-password` and
|
||||
`email-password` are "Username and password" (`PSL_ACM_USER_ID_PASSWORD`); `email-link` and `phone` are
|
||||
`PSL_ACM_USER_ID_OTHER_AUTH`; `oauth` is `PSL_ACM_OAUTH`; `other` is `PSL_ACM_OTHER`. An account alone never means
|
||||
an email address: declare Email only when the app asks for one. Google does not publish the question ids for the delete-account URL: export the form once
|
||||
(Play Console > App content > Data safety > Export to CSV), copy the id, and add it as
|
||||
`dataSafety.extra: [{ question: '<id>', value: '<deletionUrl>' }]`; otherwise enter the URL in the Console after
|
||||
`storebound play data-safety`.
|
||||
an email address: declare Email only when the app asks for one. With accounts Play requires more rows, and storebound
|
||||
writes them: the delete-account URL (`PSL_ACCOUNT_DELETION_URL` = `accounts.deletionUrl`) and "can people delete
|
||||
some or all of their data without deleting the account?" (`PSL_SUPPORT_DATA_DELETION_BY_USER` =
|
||||
`dataSafety.partialDeletion`; ask the owner, never guess), plus its web page when the answer is yes
|
||||
(`PSL_DATA_DELETION_URL` = `dataSafety.partialDeletionUrl`, default `accounts.deletionUrl`). `storebound check` and
|
||||
`play data-safety --dry` say `missing: <id> (... set <field>)` for any of them; without them Play answers 400
|
||||
"Response missing for PSL_ACCOUNT_DELETION_URL".
|
||||
5. Privacy policy: a "Deleting your account" section with both paths (`privacy-policy` skill).
|
||||
6. Test it on a fresh account before every submission; the reviewer will.
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,16 @@ CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolc
|
|||
- The Data safety CSV import **replaces** the whole form.
|
||||
- `PSL_SUPPORTED_ACCOUNT_CREATION_METHODS` (e.g. `PSL_ACM_NONE`) is required even though Google's older sample CSV
|
||||
omits it: without it the import fails with "Response missing". storebound always writes it.
|
||||
- Conditional required rows: some questions become required by another answer, and Play refuses the import with
|
||||
"Response missing for <id>" (400). Account creation not `PSL_ACM_NONE` -> `PSL_ACCOUNT_DELETION_URL` (from
|
||||
`accounts.deletionUrl`) and `PSL_SUPPORT_DATA_DELETION_BY_USER` ("delete some or all data without deleting the
|
||||
account?", `dataSafety.partialDeletion: true | false`, ask the owner); that answer true -> `PSL_DATA_DELETION_URL`
|
||||
(`dataSafety.partialDeletionUrl`, default `accounts.deletionUrl`); `PSL_HAS_OUTSIDE_APP_ACCOUNTS` is refused next
|
||||
to sign-up methods. A selected data type needs collected / shared, ephemeral, required / optional and a purpose
|
||||
(a sharing purpose when shared). `storebound check` and `play data-safety --dry` name each missing row as
|
||||
`missing: <id> (<when>; set <config field>)` and the real run stops before sending. Rules:
|
||||
`DATA_SAFETY_RULES` / `typeRules` in `cli/lib/data-safety.ts`. A `dataSafety.extra` row with a generated
|
||||
question id replaces that question (use it when your Play Console export shows other ids).
|
||||
- "Optional" data is only allowed when the user can really choose for that data; otherwise required.
|
||||
- Ads SDKs: read each network's own Play data safety guidance; adding a network can add data types.
|
||||
- Target audience under 13 pulls the app into the Families policy (certified ad networks, no ad id): only if true.
|
||||
|
|
|
|||
|
|
@ -230,6 +230,23 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
|
|||
- **Guard:** `console plan` / `console verify` (CLI and the read-only MCP tools `console_plan`, `console_verify`), the
|
||||
console-autofill skill puts them first; `tests/console-plan.test.ts`.
|
||||
|
||||
## 21. Play Data safety: conditional required rows
|
||||
- **Symptom:** `play data-safety` answers 400 "Invalid safety labels declaration: Response missing for
|
||||
PSL_ACCOUNT_DELETION_URL" (or another `PSL_...` id) although `--dry` looked fine.
|
||||
- **Cause:** some questions are required only after another answer. Account creation not `PSL_ACM_NONE` requires the
|
||||
delete-account URL (`PSL_ACCOUNT_DELETION_URL`) and the data-only deletion question
|
||||
(`PSL_SUPPORT_DATA_DELETION_BY_USER`; when yes, `PSL_DATA_DELETION_URL`), and Play then refuses
|
||||
`PSL_HAS_OUTSIDE_APP_ACCOUNTS`. A selected data type needs its whole usage block.
|
||||
- **Do:** set `accounts.deletionUrl`, ask the owner for `dataSafety.partialDeletion: true | false` (and
|
||||
`partialDeletionUrl` when true and it is another page), run `storebound check` / `play data-safety --dry` until no
|
||||
`missing: <id>` is left. If Play names an id storebound does not know, export the form (Play Console > App content >
|
||||
Data safety > Export to CSV) and add the row in `dataSafety.extra` (same id = replaces the generated row).
|
||||
- **Guard:** `DATA_SAFETY_RULES` / `typeRules` / `dataSafetyIssues` in `cli/lib/data-safety.ts`; `check`,
|
||||
`project.declarations` and `play data-safety` (dry and real, before the POST) report them.
|
||||
- **Sources:** https://support.google.com/googleplay/android-developer/answer/10787469 (Data safety form, CSV import),
|
||||
https://support.google.com/googleplay/android-developer/answer/13327111 (account deletion: delete-account link;
|
||||
deleting other data is a separate question).
|
||||
|
||||
## Adding a lesson
|
||||
Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test
|
||||
that fails without the fix (see CONTRIBUTING.md).
|
||||
|
|
|
|||
|
|
@ -102,7 +102,9 @@ const config: ShipConfig = {
|
|||
deletionRequest: true, // e.g. "email us"; no accounts, so no account deletion URL needed
|
||||
// No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no
|
||||
// email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's
|
||||
// "Username and other authentication"; `storebound scan` proposes it).
|
||||
// "Username and other authentication"; `storebound scan` proposes it). With accounts Play also requires the
|
||||
// delete-account URL (from accounts.deletionUrl) and partialDeletion: true | false (delete data without deleting the
|
||||
// account; ask the owner), plus partialDeletionUrl when true (default accounts.deletionUrl).
|
||||
accountCreation: ['PSL_ACM_NONE'],
|
||||
data: {
|
||||
PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
|
||||
|
|
|
|||
|
|
@ -139,6 +139,24 @@ describe('check: appPrivacy.types against the iOS project', () => {
|
|||
expect(msgs(r)).toContain('appPrivacy.types.GAMEPLAY_CONTENT is collected, but App/PrivacyInfo.xcprivacy has no NSPrivacyCollectedDataTypeGameplayContent');
|
||||
expect(appPrivacyMismatches({ DEVICE_ID: { collected: true, tracking: true } }, { attString: true, manifest: m(true, ['NSPrivacyCollectedDataTypeDeviceID']) })).toEqual([]);
|
||||
});
|
||||
test('storebound check: a missing accounts.deletionUrl is a Data safety problem (Play 400 otherwise)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'storebound-ds-'));
|
||||
try {
|
||||
writeFileSync(join(dir, '.gitignore'), '.storebound/\n');
|
||||
const c = { app: base.app, languages: base.languages, accounts: { enabled: true, signUp: ['username'] },
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false,
|
||||
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account'] } } } } as ShipConfig;
|
||||
const out: string[] = [];
|
||||
const orig = console.log;
|
||||
console.log = (s: string) => { out.push(s); };
|
||||
try { await check({ loaded: { config: c, root: dir, path: join(dir, 'storebound.config.ts'), resolve: p => join(dir, p) }, args: parseArgs(['--json']) }); }
|
||||
finally { console.log = orig; process.exitCode = 0; }
|
||||
const problems = (JSON.parse(out.at(-1)!).problems as string[]).join('\n');
|
||||
expect(problems).toContain('data safety: missing: PSL_ACCOUNT_DELETION_URL');
|
||||
expect(problems).toContain('set accounts.deletionUrl');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('storebound check reports the mismatches (errors are problems)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'storebound-ap-'));
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -347,6 +347,21 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri
|
|||
expect(s.play.dataSafety).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('play data-safety: a missing conditional row stops --dry and the real run before any POST', async () => {
|
||||
// STAR DASH: accounts with a username, no deletion URL in the CSV -> Play 400 "Response missing for PSL_ACCOUNT_DELETION_URL"
|
||||
const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: true, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'], partialDeletion: false,
|
||||
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account'] } } };
|
||||
const p = project({ ...CONFIG, accounts: { enabled: true, signUp: ['username'] }, dataSafety: ds as never });
|
||||
const before = s.calls.length;
|
||||
await expect(capture(() => playDataSafety(p.ctx(['--dry'])))).rejects.toThrow('missing: PSL_ACCOUNT_DELETION_URL (account creation is not PSL_ACM_NONE; set accounts.deletionUrl');
|
||||
await expect(capture(() => playDataSafety(p.ctx([])))).rejects.toThrow('PSL_ACCOUNT_DELETION_URL');
|
||||
expect(writes(s.calls.slice(before))).toEqual([]);
|
||||
const q = project({ ...CONFIG, accounts: { enabled: true, signUp: ['username'], deletionUrl: 'https://example.com/delete-account' }, dataSafety: ds as never });
|
||||
await capture(() => playDataSafety(q.ctx([])));
|
||||
expect(s.play.dataSafety.at(-1)).toContain('PSL_ACCOUNT_DELETION_URL,,https://example.com/delete-account,');
|
||||
expect(s.play.dataSafety.at(-1)).toContain('PSL_SUPPORT_DATA_DELETION_BY_USER,,FALSE,');
|
||||
});
|
||||
|
||||
test('apple promoted: created and ordered once', async () => {
|
||||
s.asc.put({ type: 'inAppPurchases', id: 'iap-pro', attributes: { productId: 'com.example.notes.pro', state: 'APPROVED' }, rels: { app: APP } });
|
||||
s.asc.put({ type: 'inAppPurchases', id: 'iap-tip', attributes: { productId: 'com.example.notes.tip', state: 'APPROVED' }, rels: { app: APP } });
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { join } from 'node:path';
|
|||
import { parseArgs } from '../cli/lib/args';
|
||||
import { loadConfig, validateConfig } from '../cli/lib/config';
|
||||
import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy';
|
||||
import { dataSafetyCsv, dataSafetyRows } from '../cli/lib/data-safety';
|
||||
import { DATA_SAFETY_RULES, dataSafetyCsv, dataSafetyIssues, dataSafetyRows } from '../cli/lib/data-safety';
|
||||
import { parseCsv } from '../cli/lib/csv';
|
||||
import { ALPHA3, appleTerritory, fromMoney, nearestPrices, playRegionConfigs, samePrice, toMoney } from '../cli/lib/money';
|
||||
import { maskExpr } from '../cli/icons';
|
||||
import { ffmpegQ } from '../cli/shots/jpeg';
|
||||
|
|
@ -99,6 +100,53 @@ describe('data safety csv', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('data safety: conditional required rows', () => {
|
||||
const acc = { ...{ collectsData: true, encryptedInTransit: true, deletionRequest: true }, accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH'],
|
||||
data: { PSL_USER_ACCOUNT: { shared: false, collect: ['account' as const] } } };
|
||||
const url = 'https://example.com/delete-account';
|
||||
const get = (rows: string[][], q: string) => rows.find(r => r[0] === q);
|
||||
test('accounts + username: the delete-account URL row carries accounts.deletionUrl', () => {
|
||||
const rows = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url });
|
||||
expect(get(rows, 'PSL_ACCOUNT_DELETION_URL')![2]).toBe(url);
|
||||
expect(get(rows, 'PSL_SUPPORT_DATA_DELETION_BY_USER')![2]).toBe('FALSE');
|
||||
expect(get(rows, 'PSL_DATA_DELETION_URL')).toBeUndefined();
|
||||
expect(dataSafetyIssues(rows)).toEqual([]);
|
||||
const yes = dataSafetyRows({ ...acc, partialDeletion: true }, { deletionUrl: url });
|
||||
expect(get(yes, 'PSL_DATA_DELETION_URL')![2]).toBe(url);
|
||||
expect(get(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: 'https://example.com/data' }, { deletionUrl: url }), 'PSL_DATA_DELETION_URL')![2]).toBe('https://example.com/data');
|
||||
});
|
||||
test('no accounts: no deletion rows, no issues', () => {
|
||||
const rows = dataSafetyRows({ ...acc, accountCreation: ['PSL_ACM_NONE'] }, { deletionUrl: url });
|
||||
for (const q of ['PSL_ACCOUNT_DELETION_URL', 'PSL_SUPPORT_DATA_DELETION_BY_USER', 'PSL_DATA_DELETION_URL']) expect(get(rows, q)).toBeUndefined();
|
||||
expect(dataSafetyIssues(rows)).toEqual([]);
|
||||
expect(get(dataSafetyRows({ ...acc, accountCreation: undefined }), 'PSL_ACCOUNT_DELETION_URL')).toBeUndefined();
|
||||
});
|
||||
test('missing deletionUrl / partialDeletion: named with the config field to set', () => {
|
||||
const msgs = dataSafetyIssues(dataSafetyRows(acc)).map(i => i.msg);
|
||||
expect(msgs[0]).toBe('missing: PSL_ACCOUNT_DELETION_URL (account creation is not PSL_ACM_NONE; set accounts.deletionUrl, a public https page to request account + data deletion (account-deletion skill))');
|
||||
expect(msgs[1]).toContain('missing: PSL_SUPPORT_DATA_DELETION_BY_USER');
|
||||
expect(msgs[1]).toContain('dataSafety.partialDeletion');
|
||||
expect(dataSafetyIssues(dataSafetyRows({ ...acc, partialDeletion: true, partialDeletionUrl: undefined }, {})).map(i => i.question)).toEqual(['PSL_ACCOUNT_DELETION_URL', 'PSL_DATA_DELETION_URL']);
|
||||
});
|
||||
test('extra replaces a generated question; refused and per-type rows', () => {
|
||||
const rows = dataSafetyRows({ ...acc, partialDeletion: false, extra: [{ question: 'PSL_ACCOUNT_DELETION_URL', value: url }] });
|
||||
expect(rows.filter(r => r[0] === 'PSL_ACCOUNT_DELETION_URL')).toHaveLength(1);
|
||||
expect(dataSafetyIssues(rows)).toEqual([]);
|
||||
const outside = dataSafetyRows({ ...acc, partialDeletion: false, extra: [{ question: 'PSL_HAS_OUTSIDE_APP_ACCOUNTS', value: 'FALSE' }] }, { deletionUrl: url });
|
||||
expect(dataSafetyIssues(outside).map(i => i.msg.split(' (')[0])).toEqual(['not allowed: PSL_HAS_OUTSIDE_APP_ACCOUNTS']);
|
||||
// a type selected but its purpose rows emptied (e.g. a hand-edited export)
|
||||
const cut = dataSafetyRows({ ...acc, partialDeletion: false }, { deletionUrl: url })
|
||||
.map(r => r[0] === 'PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE' ? [r[0], r[1]!, '', r[3]!, r[4]!] : r);
|
||||
expect(dataSafetyIssues(cut).map(i => i.question)).toEqual(['PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE']);
|
||||
});
|
||||
test('the template passes the rules and only uses known question ids', () => {
|
||||
const rows = parseCsv(readFileSync(join(import.meta.dir, '..', 'templates', 'data-safety.csv'), 'utf8'));
|
||||
expect(dataSafetyIssues(rows)).toEqual([]);
|
||||
const known = new Set(DATA_SAFETY_RULES.map(r => r.question));
|
||||
for (const r of rows.slice(1)) expect(known.has(r[0]!) || /^PSL_DATA_TYPES_|^PSL_DATA_USAGE_RESPONSES:PSL_[A-Z_]+:/.test(r[0]!)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prices', () => {
|
||||
test('toMoney without float noise', () => {
|
||||
expect(toMoney('USD', 2.99)).toEqual({ currencyCode: 'USD', units: '2', nanos: 990000000 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue