storebound/tests/guest-login-wall.test.ts

100 lines
7.6 KiB
TypeScript

// A guest-play game (no sign-in) that Play rejected as a login wall ("provide valid login credentials"): the first
// screen had an empty name field, Continue disabled until a name was typed and an "I have a code" link. The guardrails:
// `storebound check` warns when such an app has no reviewer steps, and the play-sign-in-details plan declares one entry
// without credentials when reviewAccess.instructions holds the guest steps. Pure; every id is a placeholder.
import { describe, expect, test } from 'bun:test';
import type { ShipConfig } from '../cli/lib/config';
import { consolePlan } from '../cli/lib/console-plan';
import { GUEST_ENTRY_NAME, PLAY_ACCESS_INFO_MAX, PLAY_FULL_ACCESS_BOX } from '../cli/lib/console-play-plans';
import { verifyPlayDeclaration } from '../cli/lib/console-verify';
import { profileIssues } from '../cli/lib/profile';
import { SIGNALS } from '../cli/lib/scan/rules';
const base = {
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
play: { packageName: 'com.example.game', developerId: '4444', appId: '9876' },
} as ShipConfig;
const STEPS = 'No account needed. 1) Open the app. 2) The player name is pre-filled: tap Continue. 3) "I Have a Code" is optional (moves progress from another device); skip it. Every feature works as a guest.';
const guest = { ...base, reviewAccess: { signInRequired: false, instructions: STEPS } } as ShipConfig;
describe('check: guest-play apps that Play may read as a login wall', () => {
const msg = 'Play may reject as a login wall; add guest steps';
const warns = (c: ShipConfig, firstRunGate?: string[]) => profileIssues(c, { demoAccount: false, firstRunGate }).filter(i => i.level === 'warn').map(i => i.msg);
test('a first-run gate in the code or IAP without instructions warns', () => {
const noSteps = { ...base, reviewAccess: { signInRequired: false } } as ShipConfig;
expect(warns(noSteps, ['src/onboarding.tsx:12']).find(m => m.startsWith(msg))).toContain('src/onboarding.tsx:12');
const iap = { ...noSteps, products: [{ id: 'com.example.game.coins', type: 'consumable', usd: 0.99, listings: { en: { title: 'Coins', description: 'Coins' } } }] } as ShipConfig;
expect(warns(iap).find(m => m.startsWith(msg))).toContain('in-app purchases');
// no reviewAccess at all is the same: nothing tells the reviewer how to get in
expect(warns(base, ['src/a.ts:1']).some(m => m.startsWith(msg))).toBe(true);
});
test('quiet with guest steps, without a gate, for a sign-in app and without Play', () => {
expect(warns(guest, ['src/onboarding.tsx:12']).some(m => m.startsWith(msg))).toBe(false);
expect(warns({ ...base, reviewAccess: { signInRequired: false } } as ShipConfig, []).some(m => m.startsWith(msg))).toBe(false);
expect(warns({ ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' } } as ShipConfig, ['src/a.ts:1']).some(m => m.startsWith(msg))).toBe(false);
const { play: _, ...noPlay } = base;
expect(warns({ ...noPlay, apple: { bundleId: 'com.example.game' } } as ShipConfig, ['src/a.ts:1']).some(m => m.startsWith(msg))).toBe(false);
});
test('instructions longer than Play\'s 500 characters warn', () => {
expect(warns({ ...base, reviewAccess: { signInRequired: false, instructions: 'x'.repeat(PLAY_ACCESS_INFO_MAX + 1) } } as ShipConfig).join('\n')).toContain('at most 500');
});
test('the first-run gate signal: name entry, code / transfer entry, account transfer UI', () => {
for (const t of [
'<input placeholder="Enter your name" />', "label: 'Choose a player name',", '<a>I Have a Code</a>', "title: 'Enter transfer code'",
'const transferCode = useState("")', "'Transfer your progress'", '<Text>Recovery code</Text>', "button('Restore progress')",
]) expect({ t, hit: SIGNALS.firstRunGate.test(t) }).toEqual({ t, hit: true });
for (const t of [
"import { transferCode } from './transfer';", 'const playerName = save.name;', '// enter your name later', 'const code = 42;', "'Your name is shown on the leaderboard'",
]) expect({ t, hit: SIGNALS.firstRunGate.test(t) }).toEqual({ t, hit: false });
});
});
describe('plan play-sign-in-details: guest steps', () => {
test('instructions with no sign-in: restricted, one entry without credentials, the box is the owner\'s', () => {
const p = consolePlan('play-sign-in-details', guest);
expect(p.answers.find(a => a.id === 'access')).toMatchObject({ value: true, status: 'certain', label: 'All or some functionality in my app is restricted' });
expect(p.answers.find(a => a.id === 'entryName')).toMatchObject({ value: GUEST_ENTRY_NAME, status: 'certain', action: 'type' });
expect(p.answers.find(a => a.id === 'credentials')).toMatchObject({ value: null, status: 'certain', text: 'leave both empty (no account)' });
expect(p.answers.find(a => a.id === 'credentials')!.action).toBeUndefined();
expect(p.answers.find(a => a.id === 'instructions')).toMatchObject({ value: STEPS, status: 'certain', label: 'Any other information required to access your app' });
expect(p.answers.find(a => a.id === 'fullAccess')).toMatchObject({ status: 'agreement', value: null, control: 'checkbox', label: PLAY_FULL_ACCESS_BOX });
expect(p.answers.find(a => a.id === 'fullAccess')!.action).toBeUndefined();
// the agent presses Add instructions, Add and Save; the box and Send changes for review are the owner's
expect(p.buttons.allowed.map(b => b.button)).toEqual(expect.arrayContaining(['Add instructions', 'Add', 'Save']));
expect(p.buttons.final).toBe('Send changes for review');
expect(p.buttons.neverPress).toContain('Send changes for review');
expect(p.ownerBatch.map(o => [o.when, o.kind])).toEqual([['after', 'agreement'], ['after', 'final']]);
expect(p.ownerBatch[0]!.text).toContain(PLAY_FULL_ACCESS_BOX);
expect(p.askOwner.join('\n')).toContain('the owner ticks it');
const text = p.steps.map(s => s.text).join('\n');
expect(text).toContain(`"${GUEST_ENTRY_NAME}"`);
expect(text).toContain('leave Username and Password empty');
expect(text).toMatch(/unticked and stop/);
});
test('verify reads the entry name and the steps back, never the box', () => {
const page = `Sign in details\nAll or some functionality in my app is restricted\n${GUEST_ENTRY_NAME}\n${STEPS}`;
const r = verifyPlayDeclaration('play-sign-in-details', guest, page);
expect(r.rows.filter(x => x.status === 'match').map(x => x.item)).toEqual(['Is any functionality restricted (sign-in, membership, location)?', 'Name of the instructions entry', 'Instructions for the reviewer']);
expect(r.rows.some(x => /full access/.test(x.item))).toBe(false);
expect(r.mismatches).toBe(0);
});
test('steps over 500 characters are an owner check', () => {
const p = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: false, instructions: 'x'.repeat(PLAY_ACCESS_INFO_MAX + 1) } } as ShipConfig);
expect(p.answers.find(a => a.id === 'instructions')).toMatchObject({ status: 'owner-check' });
});
test('no instructions: the "No restrictions" path stays', () => {
const p = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: false } } as ShipConfig);
expect(p.answers.map(a => [a.id, a.value])).toEqual([['access', false]]);
expect(p.answers[0]!.label).toBe('All functionality in my app is available without any access restrictions');
expect(p.buttons.allowed.map(b => b.button)).not.toContain('Add');
expect(p.ownerBatch.map(o => o.kind)).toEqual(['final']);
});
});