codeburn/tests/sync-attribution-cli.test.ts
Andrew Lee ccee28ae82 fix(sync): address attribution review — cwd-fallback egress, Windows paths, PR-link validation
Review findings on the --attribution PR:

- Privacy: sessions whose project path no longer resolves inherited the
  cwd-fallback repo identity, egressing whatever (possibly confidential)
  repo the user pushes from and falsely attributing its commits.
  buildRepoGroups now tracks per-session identity provenance; the
  attribution path excludes fallback sessions from commit attribution
  entirely (no repo, no commits, PR links only) — they also can no
  longer steal a commit from a genuine session's window.
- Privacy: Windows drive-letter paths (C:/..., C:\..., drive-relative)
  parsed as scp-like remotes, emitting local filesystem paths as repo
  identities. normalizeRemoteUrl rejects drive letters and
  single-character hosts (dotless intranet hosts still accepted).
- Hardening: PR links are shape-checked before sending (https,
  /org/repo/pull/N path, <=256 chars, max 20 per session) — upstream
  parsers only truthiness-check them.
- Safety valve: MAX_ATTRIBUTION_PER_PUSH (10k) caps a first
  --since all --attribution push; dry-run reports the cap.
- Tests: adversarial normalize corpus, cwd-fallback egress repro,
  commit-stealing prevention, PR-link sanitization, and CLI-level tests
  (mock IdP + collector): dry-run sends nothing to the traces endpoint,
  flag-off emits no attribution span names on the wire.
- Docs: reconciled the 'never sent' wording with reality (PR links ride
  even when repo is null; device_id/methodology/timestamps disclosed).
  CHANGELOG Unreleased entry added.

AI-Origin: human
2026-08-02 12:29:18 +00:00

157 lines
6.8 KiB
TypeScript

/**
* CLI-level tests for `codeburn sync push --attribution`.
*
* Drives the real commander action against a mock IdP + collector:
* - --dry-run --attribution: NO telemetry reaches the traces endpoint
* - push WITHOUT the flag: no attribution span names on the wire
* - push WITH the flag: attribution spans arrive alongside usage spans
*/
import { execFileSync } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
import { Command } from 'commander'
import { startMockIdp, type MockIdp } from './fixtures/mock-idp.js'
import type { ProjectSummary, SessionSummary, ParsedApiCall, TokenUsage } from '../src/types.js'
const { parseAllSessionsMock } = vi.hoisted(() => ({ parseAllSessionsMock: vi.fn() }))
vi.mock('../src/parser.js', () => ({ parseAllSessions: parseAllSessionsMock }))
function git(cwd: string, args: string[], env: Record<string, string> = {}): string {
return execFileSync('git', args, { cwd, encoding: 'utf-8', env: { ...process.env, ...env } }).trim()
}
function makeUsage(): TokenUsage {
return { inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }
}
function makeCall(key: string, ts: string): ParsedApiCall {
return {
provider: 'test', model: 'test-model', usage: makeUsage(), costUSD: 0.01,
tools: [], mcpTools: [], skills: [], subagentTypes: [], hasAgentSpawn: false,
hasPlanMode: false, speed: 'standard', timestamp: ts, bashCommands: [],
deduplicationKey: key,
}
}
function makeSession(id: string, first: string, last: string, calls: ParsedApiCall[]): SessionSummary {
return {
sessionId: id, project: 'app', firstTimestamp: first, lastTimestamp: last,
totalCostUSD: 0.01, totalSavingsUSD: 0, totalInputTokens: 10, totalOutputTokens: 5,
totalReasoningTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: calls.length,
turns: [{ userMessage: 'x', assistantCalls: calls, timestamp: first, sessionId: id, category: 'coding', retries: 0, hasEdits: false }],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'], skillBreakdown: {}, subagentBreakdown: {},
}
}
let idp: MockIdp
let tmpHome: string
let repoDir: string
const originalHome = process.env.HOME
const originalXdg = process.env.XDG_CACHE_HOME
const originalStore = process.env.CODEBURN_SYNC_TOKEN_STORE
async function runPush(args: string[]): Promise<void> {
const { registerSyncCommands } = await import('../src/sync/cli.js')
const program = new Command()
program.exitOverride() // throw instead of process.exit on commander errors
registerSyncCommands(program)
await program.parseAsync(['node', 'codeburn', 'sync', 'push', ...args])
}
beforeAll(async () => {
idp = await startMockIdp({ rotateTokens: false })
process.env.CODEBURN_SYNC_TOKEN_STORE = 'file'
// Real git repo with a remote — a recent commit inside the session window
repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-repo-'))
git(repoDir, ['init', '-b', 'main'])
git(repoDir, ['config', 'user.email', 't@e.com'])
git(repoDir, ['config', 'user.name', 'T'])
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/cli-widget.git'])
await writeFile(join(repoDir, 'f.txt'), 'x\n')
const commitIso = new Date(Date.now() - 60 * 60 * 1000).toISOString()
git(repoDir, ['add', '.'])
git(repoDir, ['commit', '-m', 'feat: recent'], { GIT_AUTHOR_DATE: commitIso, GIT_COMMITTER_DATE: commitIso })
})
afterAll(async () => {
await idp.close()
await rm(repoDir, { recursive: true, force: true })
if (originalStore === undefined) delete process.env.CODEBURN_SYNC_TOKEN_STORE
else process.env.CODEBURN_SYNC_TOKEN_STORE = originalStore
})
beforeEach(async () => {
tmpHome = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-'))
process.env.HOME = tmpHome
process.env.XDG_CACHE_HOME = join(tmpHome, '.cache')
idp.tracesRequests.length = 0
// Configure sync against the mock IdP + store the refresh token
const { writeSyncConfig } = await import('../src/sync/config.js')
const { createCredentialStore } = await import('../src/sync/credentials.js')
writeSyncConfig({ baseUrl: idp.baseUrl, clientId: 'mock-client-id', tracesPath: '/v1/traces', issuer: idp.baseUrl })
createCredentialStore().store('mock-refresh-token-v1')
// One session in the last hour, working in the real repo
const now = Date.now()
const first = new Date(now - 90 * 60 * 1000).toISOString()
const last = new Date(now - 30 * 60 * 1000).toISOString()
const session = makeSession('cli-sess-1', first, last, [makeCall(`call-${now}`, first)])
parseAllSessionsMock.mockResolvedValue([
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
])
// The push action reads process.cwd() for the attribution cwd — run from
// a neutral non-repo dir so nothing can come from a cwd fallback.
vi.spyOn(process, 'cwd').mockReturnValue(tmpHome)
})
afterEach(async () => {
vi.restoreAllMocks()
process.exitCode = 0
process.env.HOME = originalHome
if (originalXdg === undefined) delete process.env.XDG_CACHE_HOME
else process.env.XDG_CACHE_HOME = originalXdg
await rm(tmpHome, { recursive: true, force: true })
})
const spanNames = (): string[] =>
idp.tracesRequests.flatMap(r => {
const body = r.body as { resourceSpans?: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> }
return (body.resourceSpans ?? []).flatMap(rs => rs.scopeSpans.flatMap(ss => ss.spans.map(s => s.name)))
})
describe('sync push --attribution (CLI level)', () => {
it('--dry-run --attribution sends nothing to the traces endpoint', async () => {
await runPush(['--dry-run', '--attribution'])
expect(idp.tracesRequests).toHaveLength(0)
})
it('push WITHOUT --attribution never emits attribution span names', async () => {
await runPush([])
expect(idp.tracesRequests.length).toBeGreaterThan(0)
const names = spanNames()
expect(names.length).toBeGreaterThan(0)
expect(names).not.toContain('codeburn.session.attribution')
expect(names).not.toContain('codeburn.commit')
expect(JSON.stringify(idp.tracesRequests)).not.toContain('git.sha')
})
it('push WITH --attribution emits usage + attribution spans', async () => {
await runPush(['--attribution'])
const names = spanNames()
expect(names).toContain('test/test-model') // usage span
expect(names).toContain('codeburn.session.attribution') // session span
expect(names).toContain('codeburn.commit') // commit span
const wire = JSON.stringify(idp.tracesRequests)
expect(wire).toContain('github.com/acme/cli-widget')
})
})