@@ -52,6 +52,16 @@ async function createFixtureProject(name = 'Remix Fixture', sceneName = 'Remix M
5252 return { id, scenePath } ;
5353}
5454
55+ /** Recursively collect every object key path in a JSON value. */
56+ function flattenKeys ( value : unknown , prefix = '' ) : string [ ] {
57+ if ( value === null || typeof value !== 'object' ) return [ ] ;
58+ if ( Array . isArray ( value ) ) return value . flatMap ( ( v ) => flattenKeys ( v , prefix ) ) ;
59+ return Object . entries ( value as Record < string , unknown > ) . flatMap ( ( [ k , v ] ) => [
60+ prefix ? `${ prefix } .${ k } ` : k ,
61+ ...flattenKeys ( v , prefix ? `${ prefix } .${ k } ` : k ) ,
62+ ] ) ;
63+ }
64+
5565async function shareProject ( app : any , projectId : string ) {
5666 const res = await app . inject ( { method : 'POST' , url : `/api/projects/${ projectId } /share` } ) ;
5767 expect ( res . statusCode ) . toBe ( 201 ) ;
@@ -204,3 +214,105 @@ describe('serialized payload size cap (design §4)', () => {
204214 expect ( ( ) => assertPayloadWithinSize ( oversized ) ) . toThrow ( / t o o l a r g e / i) ;
205215 } ) ;
206216} ) ;
217+
218+ describe ( 'share counters (slice 3, CEO ruling #4: aggregate integers only, zero PII)' , ( ) => {
219+ it ( 'fresh shares start at zero and GET /api/share/:token/stats returns exactly {plays, remixes}' , async ( ) => {
220+ const app = await buildApp ( ) ;
221+ const { id : projectId } = await createFixtureProject ( 'Stats Fresh' ) ;
222+ const { hosted } = await shareProject ( app , projectId ) ;
223+
224+ const res = await app . inject ( { method : 'GET' , url : `/api/share/${ hosted . id } /stats` } ) ;
225+ expect ( res . statusCode ) . toBe ( 200 ) ;
226+ expect ( res . json ( ) ) . toEqual ( { plays : 0 , remixes : 0 } ) ;
227+
228+ // CORS header is mandatory: the injected landing bar fetches stats from a
229+ // CSP-sandboxed page with an opaque origin (cross-origin request).
230+ expect ( res . headers [ 'access-control-allow-origin' ] ) . toBe ( '*' ) ;
231+ expect ( res . headers [ 'cache-control' ] ) . toBe ( 'no-store' ) ;
232+
233+ await app . close ( ) ;
234+ } ) ;
235+
236+ it ( 'serving the game increments plays — /share/:token and legacy view route both count' , async ( ) => {
237+ const app = await buildApp ( ) ;
238+ const { id : projectId } = await createFixtureProject ( 'Stats Plays' ) ;
239+ const { hosted } = await shareProject ( app , projectId ) ;
240+
241+ await app . inject ( { method : 'GET' , url : `/share/${ hosted . id } ` } ) ;
242+ await app . inject ( { method : 'GET' , url : `/api/hosted/${ hosted . id } /view` } ) ;
243+ await app . inject ( { method : 'GET' , url : `/share/${ hosted . id } ` } ) ;
244+
245+ const res = await app . inject ( { method : 'GET' , url : `/api/share/${ hosted . id } /stats` } ) ;
246+ expect ( res . json ( ) ) . toEqual ( { plays : 3 , remixes : 0 } ) ;
247+
248+ await app . close ( ) ;
249+ } ) ;
250+
251+ it ( 'remix payload fetches increment remixes; failed/legacy remix attempts do not' , async ( ) => {
252+ const app = await buildApp ( ) ;
253+ const { id : projectId } = await createFixtureProject ( 'Stats Remixes' ) ;
254+ const { hosted } = await shareProject ( app , projectId ) ;
255+
256+ await app . inject ( { method : 'GET' , url : `/api/share/${ hosted . id } /remix` } ) ;
257+ await app . inject ( { method : 'GET' , url : `/api/share/${ hosted . id } /remix` } ) ;
258+
259+ // Legacy share without sidecar → typed 404 must NOT count as a remix.
260+ const other = await createFixtureProject ( 'Legacy Stats' ) ;
261+ const legacyShare = await shareProject ( app , other . id ) ;
262+ await unlink ( join ( HOSTED_DIR , `${ legacyShare . hosted . id } .share.json` ) ) ;
263+ await app . inject ( { method : 'GET' , url : `/api/share/${ legacyShare . hosted . id } /remix` } ) ;
264+ expect (
265+ ( await app . inject ( { method : 'GET' , url : `/api/share/${ legacyShare . hosted . id } /stats` } ) ) . json ( ) ,
266+ ) . toEqual ( { plays : 0 , remixes : 0 } ) ;
267+
268+ const res = await app . inject ( { method : 'GET' , url : `/api/share/${ hosted . id } /stats` } ) ;
269+ expect ( res . json ( ) ) . toEqual ( { plays : 0 , remixes : 2 } ) ;
270+
271+ await app . close ( ) ;
272+ } ) ;
273+
274+ it ( '404 unknown token, 410 expired token for stats' , async ( ) => {
275+ const app = await buildApp ( ) ;
276+ const missing = await app . inject ( { method : 'GET' , url : '/api/share/no-such-token/stats' } ) ;
277+ expect ( missing . statusCode ) . toBe ( 404 ) ;
278+
279+ const service = new HostedService ( mockLogger ) ;
280+ const { id : projectId } = await createFixtureProject ( 'Expired Stats' ) ;
281+ const { ExportService } = await import ( '../services/exportService' ) ;
282+ const exportService = new ExportService ( mockLogger ) ;
283+ const exported = await exportService . exportToPhaserHTML ( projectId , { format : 'phaser-html' } ) ;
284+ const hosted = await service . hostExport ( projectId , exported . filename , { expiresInDays : 30 } ) ;
285+ const metaPath = join ( HOSTED_DIR , `${ hosted . id } .meta.json` ) ;
286+ const meta = JSON . parse ( await readFile ( metaPath , 'utf-8' ) ) ;
287+ meta . expiresAt = new Date ( Date . now ( ) - 1000 ) . toISOString ( ) ;
288+ await writeFile ( metaPath , JSON . stringify ( meta , null , 2 ) , 'utf-8' ) ;
289+
290+ const expired = await app . inject ( { method : 'GET' , url : `/api/share/${ hosted . id } /stats` } ) ;
291+ expect ( expired . statusCode ) . toBe ( 410 ) ;
292+
293+ await app . close ( ) ;
294+ } ) ;
295+
296+ it ( 'meta file carries integers only — no PII keys ever written by counter paths' , async ( ) => {
297+ const app = await buildApp ( ) ;
298+ const { id : projectId } = await createFixtureProject ( 'Stats No PII' ) ;
299+ const { hosted } = await shareProject ( app , projectId ) ;
300+
301+ await app . inject ( { method : 'GET' , url : `/share/${ hosted . id } ` } ) ;
302+ await app . inject ( { method : 'GET' , url : `/api/share/${ hosted . id } /remix` } ) ;
303+
304+ const raw = await readFile ( join ( HOSTED_DIR , `${ hosted . id } .meta.json` ) , 'utf-8' ) ;
305+ const meta = JSON . parse ( raw ) ;
306+ expect ( meta . counts ) . toEqual ( { plays : 1 , remixes : 1 } ) ;
307+ expect ( Number . isInteger ( meta . counts . plays ) ) . toBe ( true ) ;
308+ expect ( Number . isInteger ( meta . counts . remixes ) ) . toBe ( true ) ;
309+
310+ const FORBIDDEN = [ 'ip' , 'ips' , 'useragent' , 'user-agent' , 'ua' , 'referer' , 'referrer' , 'fingerprint' , 'sessionid' ] ;
311+ const keys = Object . keys ( flattenKeys ( meta ) ) . map ( ( k ) => k . toLowerCase ( ) ) ;
312+ for ( const forbidden of FORBIDDEN ) {
313+ expect ( keys . some ( ( k ) => k === forbidden || k . endsWith ( `.${ forbidden } ` ) ) ) . toBe ( false ) ;
314+ }
315+
316+ await app . close ( ) ;
317+ } ) ;
318+ } ) ;
0 commit comments