// fastlane left in a project (Fastfile, CI calls to supply / deliver / pilot) is found and named (pitfall 96). import { describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { findFastlaneTraces, traceLine, traceMessage } from '../cli/lib/fastlane-traces'; import { runScan } from '../cli/lib/scan'; function project(files: Record): string { const dir = mkdtempSync(join(tmpdir(), 'sb-fl-')); for (const [p, text] of Object.entries(files)) { mkdirSync(join(dir, p, '..'), { recursive: true }); writeFileSync(join(dir, p), text); } return dir; } describe('fastlane traces', () => { test('Fastfile actions, Appfile, and CI / script calls with file:line', () => { const dir = project({ 'android/fastlane/Fastfile': 'lane :ship do\n # supply in a comment is not a call\n supply(track: "internal")\nend\n', 'fastlane/Appfile': 'app_identifier("com.example.game")\n', 'Jenkinsfile': 'stage("upload") {\n sh "bundle exec fastlane supply --aab app.aab"\n}\n', '.github/workflows/ios.yml': 'steps:\n - run: fastlane pilot upload\n', 'scripts/release.sh': 'echo fastlane is fine to mention\n', }); const t = findFastlaneTraces(dir); expect(t).toContainEqual({ file: 'android/fastlane/Fastfile', line: 3, text: 'supply(track: "internal")', store: 'play' }); expect(t).toContainEqual({ file: 'fastlane/Appfile', line: 1, text: 'app_identifier("com.example.game")', store: 'both' }); expect(t).toContainEqual({ file: 'Jenkinsfile', line: 2, text: 'sh "bundle exec fastlane supply --aab app.aab"', store: 'play' }); expect(t).toContainEqual({ file: '.github/workflows/ios.yml', line: 2, text: '- run: fastlane pilot upload', store: 'apple' }); expect(t.some(x => x.file === 'scripts/release.sh')).toBe(false); expect(t.filter(x => x.file === 'android/fastlane/Fastfile')).toHaveLength(1); expect(traceLine(t.find(x => x.file === 'Jenkinsfile')!)).toBe('fastlane in Jenkinsfile:2: sh "bundle exec fastlane supply --aab app.aab"'); }); test('scripts that call the store APIs or upload tools themselves, with the storebound way', () => { const dir = project({ 'scripts/play-upload.mjs': "const url = 'https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/com.example.app/edits';\n", 'tools/asc.py': "BASE = 'https://api.appstoreconnect.apple.com/v1'\n", 'deploy/ios.sh': 'xcrun altool --upload-app -f app.ipa\n', 'package.json': '{"name":"an-app","scripts":{"ship":"node scripts/play-upload.mjs"}}', }); const t = findFastlaneTraces(dir); expect(t.map(x => `${x.file} ${x.store} ${x.via}`).sort()).toEqual(['deploy/ios.sh apple api', 'scripts/play-upload.mjs play api', 'tools/asc.py apple api']); expect(traceMessage(t.find(x => x.store === 'play')!)).toContain('play_release_testing'); expect(traceMessage(t.find(x => x.file === 'deploy/ios.sh')!)).toStartWith('altool / Transporter upload in deploy/ios.sh:1'); }); test("storebound's own checkout is not reported", () => { expect(findFastlaneTraces(project({ 'package.json': '{"name":"storebound"}', 'scripts/x.ts': "fetch('https://api.appstoreconnect.apple.com/v1/apps')\n" }))).toEqual([]); }); test('a project without fastlane has no traces', () => { expect(findFastlaneTraces(project({ 'package.json': '{"scripts":{"build":"vite build"}}' }))).toEqual([]); }); test('scan reports fastlane-upload with the evidence lines', async () => { const dir = project({ 'Jenkinsfile': 'sh "fastlane supply"\n' }); const { report } = await runScan(dir, {}); const f = report.findings.find(x => x.rule === 'fastlane-upload' && x.store === 'play'); expect(f?.evidence[0]).toMatchObject({ file: 'Jenkinsfile', line: 1 }); expect(f?.fix).toContain('storebound import fastlane'); }); }); describe('direct store API traces stay short', () => { test('comment lines are skipped and each file names a tool once', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-fl2-')); mkdirSync(join(dir, 'tools')); writeFileSync(join(dir, 'tools/ship.mjs'), "/**\n * 4. `xcrun altool --upload-package` uploads it\n */\n// altool needs the app id\nconst words = ['xcrun', 'altool', '--upload-package'];\nrun(['xcrun', 'altool', '--validate-app']);\n"); expect(findFastlaneTraces(dir)).toEqual([expect.objectContaining({ file: 'tools/ship.mjs', line: 5, via: 'api' })]); }); }); describe('test files are not traces', () => { test('a test that quotes altool is skipped', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-fl3-')); mkdirSync(join(dir, 'tools')); writeFileSync(join(dir, 'tools/ship.test.mjs'), "test('calls altool', () => { run(['xcrun', 'altool']); });\n"); expect(findFastlaneTraces(dir)).toEqual([]); }); });