codeburn/packages/cli/tests/sync-attribution.test.ts
ozymandiashh f29d27e39d feat(sync): push git attribution spans, with the hardening that followed
Ports three upstream commits this branch never received: the attribution
feature (1bf7206), the review hardening on top of it (ccee28a), and the
security follow-up that closed credential-leak paths and added session
retraction (50c8251).

They are ported as an end state rather than in sequence. Two and three revise
one, so replaying them in order would have introduced the very issues they fix
and then removed them again — and anything missed in the third pass would have
shipped a feature with a reopened hole, which is the specific way this port
could have gone wrong. The credential-leak paths that commit closes are
enumerated and checked off individually against the result.

One correction to an earlier draft of this message, which claimed no new
unkeyed digest is introduced. That was wrong: stateHash in sync/otlp.ts is a
new unkeyed sha256, and it feeds deriveSpanId, so it is an input to a value
that goes on the wire. It is not a D1 violation — D1 governs core's fingerprint
module and its caller-supplied key, while stateHash is a local ledger
discriminator computed over data that is itself sent in cleartext, so it hides
nothing and leaks nothing. But the sentence was false and is worth correcting
rather than quietly dropping.

This branch now carries #931's commit (c467548, "fix(sync): key the device,
span and trace digests") beneath this one — cherry-picked onto the shared base
so the history stays two clean commits. That ordering is load-bearing:
reconciliation is mandatory in every merge order, not optional. git merge-tree
reports no conflict against #931 in either direction, yet the merged file does
not compile: #931 drops the createHash import and gives the derive functions a
privacyKey first argument, so an unreconciled attribution section leaves
stateHash with an undefined symbol and two one-argument call sites. Rebasing
replays the same breakage, which is why the earlier "land #931 first, OR
reconcile" framing was wrong.

The two call sites are reconciled INTO #931's keyed signatures, in the
direction #931 demands: buildAttributionOtlpPayload obtains the persisted host
privacy key exactly as buildOtlpPayload does — one getPersistedHostPrivacyKey
call per builder, no second source of the key — and threads it into
deriveTraceId and deriveSpanId. This is the security point of the
reconciliation: loosening the signatures back to one argument would
reintroduce exactly the unkeyed span and trace ids #931 exists to remove, in
new code.

stateHash stays unkeyed, deliberately: it is a local ledger discriminator over
One more merge-compat fix, in #931's own test file (sync-privacy-key.test.ts):
the concurrency fixture path was built from process.cwd(), which is the repo
root under `--root packages/cli` — the worker then exited on a nonexistent
file before writing its ready file and the race test timed out. The path is
now anchored to the test file's own location (fileURLToPath(import.meta.url)).
This is the only line of #931's tree this branch touches; 37a5b46 remains a
verbatim copy of c467548.

Second fix in #931's tree, same motivation: the concurrency race test
adopted with only a 50ms budget. createKeyFileExclusive polled the winner's
file 5x10ms after EEXIST, and the strict entry check refused an 'invalid'
file INSTANTLY — but the winner's create (open) and write are separate
syscalls, and under load the loser can read the still-empty file either at
entry or inside the poll. Both windows now share one bounded awaitValidKey
(500ms) that ADOPTS the winner's key when it lands and otherwise throws the
same refusal. Nothing is ever overwritten; a file left invalid by a crash or
truncated write still fails loudly. This is the second #931 file this branch
touches; 37a5b46 remains a verbatim copy of c467548.
2026-08-05 18:50:23 +03:00

756 lines
34 KiB
TypeScript

/**
* Tests for sync git attribution (sync push --attribution).
*
* Covers: remote URL normalization, session→commit attribution record
* computation (reusing the yield engine), state-encoding dedup keys,
* attribution OTLP span construction, and the send/ledger pipeline.
*/
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 { createServer, type Server } from 'node:http'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
import {
normalizeRemoteUrl,
computeAttributionRecords,
sanitizePrLinks,
MAX_PR_LINKS_PER_SESSION,
type SessionAttributionRecord,
} from '../src/yield.js'
import {
flattenAttributionRecords,
commitAttributionKey,
sessionAttributionKey,
buildAttributionOtlpPayload,
batchAttributionItems,
deriveTraceId,
SESSION_ATTRIBUTION_SPAN_NAME,
COMMIT_ATTRIBUTION_SPAN_NAME,
type OtlpAttribute,
} from '../src/sync/otlp.js'
// ── Git fixtures (mirrors yield-repo-grouping.test.ts) ────────────────
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 initRepo(dir: string): void {
git(dir, ['init', '-b', 'main'])
git(dir, ['config', 'user.email', 'test@example.com'])
git(dir, ['config', 'user.name', 'Test'])
}
function commitAt(dir: string, message: string, iso: string): void {
git(dir, ['add', '.'])
git(dir, ['commit', '-m', message], {
GIT_AUTHOR_DATE: iso,
GIT_COMMITTER_DATE: iso,
})
}
function makeSession(overrides: Partial<SessionSummary>): SessionSummary {
return {
sessionId: 'session',
project: 'app',
firstTimestamp: '2026-01-01T10:00:00.000Z',
lastTimestamp: '2026-01-01T11:00:00.000Z',
totalCostUSD: 1,
totalSavingsUSD: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {},
subagentBreakdown: {},
...overrides,
}
}
const range = {
start: new Date('2026-01-01T00:00:00.000Z'),
end: new Date('2026-01-02T00:00:00.000Z'),
}
// ── normalizeRemoteUrl ────────────────────────────────────────────────
describe('normalizeRemoteUrl', () => {
it('normalizes scp-like ssh remotes', () => {
expect(normalizeRemoteUrl('git@github.com:acme/widget.git')).toBe('github.com/acme/widget')
expect(normalizeRemoteUrl('git@GitHub.com:Acme/Widget')).toBe('github.com/Acme/Widget')
})
it('normalizes ssh:// remotes, dropping user and port', () => {
expect(normalizeRemoteUrl('ssh://git@github.com/acme/widget.git')).toBe('github.com/acme/widget')
expect(normalizeRemoteUrl('ssh://git@gitlab.example.com:2222/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo')
})
it('normalizes https remotes and strips embedded credentials', () => {
expect(normalizeRemoteUrl('https://github.com/acme/widget.git')).toBe('github.com/acme/widget')
expect(normalizeRemoteUrl('https://user:s3cret-token@github.com/acme/widget.git')).toBe('github.com/acme/widget')
expect(normalizeRemoteUrl('https://github.com/acme/widget/')).toBe('github.com/acme/widget')
})
it('returns null for local paths and file:// remotes', () => {
expect(normalizeRemoteUrl('/home/dev/repos/widget')).toBeNull()
expect(normalizeRemoteUrl('file:///home/dev/repos/widget')).toBeNull()
expect(normalizeRemoteUrl('../relative/repo')).toBeNull()
expect(normalizeRemoteUrl('')).toBeNull()
})
it('rejects Windows drive-letter paths (never a remote identity)', () => {
expect(normalizeRemoteUrl('C:/Users/alice/private/repo')).toBeNull()
expect(normalizeRemoteUrl('C:\\Users\\alice\\private\\repo')).toBeNull()
expect(normalizeRemoteUrl('c:/repo')).toBeNull()
expect(normalizeRemoteUrl('Z:\\work\\nda-client-repo')).toBeNull()
// Drive-relative (no slash after colon) — single-char host rejection
expect(normalizeRemoteUrl('C:repo')).toBeNull()
expect(normalizeRemoteUrl('c:relative\\path')).toBeNull()
})
it('rejects other adversarial forms without over-rejecting real remotes', () => {
// Single-character "host" is never a real remote host
expect(normalizeRemoteUrl('a:path/to/repo')).toBeNull()
expect(normalizeRemoteUrl('git@C:/foo')).toBeNull()
// Dotless intranet hosts remain valid (2+ chars)
expect(normalizeRemoteUrl('gitserver:team/repo.git')).toBe('gitserver/team/repo')
expect(normalizeRemoteUrl('git@gitbox:org/repo.git')).toBe('gitbox/org/repo')
})
it('never leaks credentials via scp-branch backtracking on malformed remotes', () => {
// Credential-prefixed remotes: the userinfo split happens BEFORE host
// matching, so a token can never be re-parsed as host:path.
expect(normalizeRemoteUrl('x-access-token:ghp_LIVETOKEN_abcdefghijklmnop@github.com/acme/private-repo.git')).toBeNull()
expect(normalizeRemoteUrl('oauth2:glpat-TOKEN@gitlab.com/org/repo.git')).toBeNull()
// One dropped slash: not a URL, must not fall through as host "https"
expect(normalizeRemoteUrl('https:/user:ghp_TOKEN@github.com/org/repo.git')).toBeNull()
// Multiple @: split at the first, residual @ fails the allow-list
expect(normalizeRemoteUrl('a@b@github.com:org/repo.git')).toBeNull()
expect(normalizeRemoteUrl('user@host:path@with-at')).toBeNull()
})
it('rejects transport-helper remotes and enforces shape + length on the identity', () => {
// git-remote-ext: embeds a local SSH key path
expect(normalizeRemoteUrl('ext::ssh -i /Users/me/.ssh/id_ed25519_work git@github.com %S /acme/private.git')).toBeNull()
expect(normalizeRemoteUrl('ext::sh -c whatever')).toBeNull()
// git-remote-codecommit: embeds an AWS profile name
expect(normalizeRemoteUrl('codecommit::us-east-1://MyAwsProfile@MyRepo')).toBeNull()
expect(normalizeRemoteUrl('codecommit::us-east-1://MyRepo')).toBeNull()
// Length bound
expect(normalizeRemoteUrl(`git@github.com:org/${'a'.repeat(300)}.git`)).toBeNull()
// Path segments must be repo-shaped (no spaces, colons, @)
expect(normalizeRemoteUrl('gitserver:has space/repo.git')).toBeNull()
// Legit multi-segment (GitLab subgroup) paths survive the allow-list
expect(normalizeRemoteUrl('https://gitlab.example.com/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo')
})
it('normalizes .GIT case-insensitively and collapses doubled slashes to one join key', () => {
expect(normalizeRemoteUrl('git@github.com:acme/Repo.GIT')).toBe('github.com/acme/Repo')
expect(normalizeRemoteUrl('https://github.com/acme/Repo.Git')).toBe('github.com/acme/Repo')
expect(normalizeRemoteUrl('https://github.com/acme//repo.git')).toBe('github.com/acme/repo')
expect(normalizeRemoteUrl('git@github.com:acme//repo.git')).toBe('github.com/acme/repo')
})
})
// ── computeAttributionRecords ─────────────────────────────────────────
describe('computeAttributionRecords', () => {
it('attributes commits with normalized remote, inMain, and timestamps', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-repo-'))
try {
initRepo(repoDir)
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
commitAt(repoDir, 'feat: shipped', '2026-01-01T10:30:00Z')
const sha = git(repoDir, ['rev-parse', 'HEAD'])
const session = makeSession({
sessionId: 'sess-a',
prLinks: ['https://github.com/acme/widget/pull/12'],
})
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
]
const records = computeAttributionRecords(projects, range, repoDir)
expect(records).toHaveLength(1)
const record = records[0]!
expect(record.sessionId).toBe('sess-a')
expect(record.repo).toBe('github.com/acme/widget')
expect(record.prLinks).toEqual(['https://github.com/acme/widget/pull/12'])
expect(record.commits).toHaveLength(1)
expect(record.commits[0]).toMatchObject({ sha, inMain: true, wasReverted: false })
expect(new Date(record.commits[0]!.timestamp).toISOString()).toBe('2026-01-01T10:30:00.000Z')
expect(record.firstTimestamp).toBe('2026-01-01T10:00:00.000Z')
expect(record.lastTimestamp).toBe('2026-01-01T11:00:00.000Z')
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
it('omits empty sessions that lost nothing — commits aged out of range are NOT retracted', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-empty-'))
try {
initRepo(repoDir)
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
// Commit outside every session window: no session competes for it, so
// nobody "lost" it. Even if this session previously synced a commit
// (now outside the --since range), emitting an empty record here would
// permanently zero a still-correct server-side count.
commitAt(repoDir, 'feat: unrelated', '2026-01-01T20:00:00Z')
const session = makeSession({ sessionId: 'sess-idle' })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
]
expect(computeAttributionRecords(projects, range, repoDir)).toEqual([])
// …and therefore nothing can be sent, even with prior ledger state for
// this session (simulating an earlier wider---since push).
const { writeLedger } = await import('../src/sync/ledger.js')
writeLedger([{ key: 'attr:s:sess-idle:0123456789abcdef', ts: '2026-01-01T10:00:00.000Z' }])
const { collectUnsentAttribution } = await import('../src/sync/push.js')
expect(collectUnsentAttribution(computeAttributionRecords(projects, range, repoDir)).unsent).toEqual([])
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
it('emits a retraction candidate for a session that lost its commit to a tighter window', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-lost-'))
try {
initRepo(repoDir)
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
commitAt(repoDir, 'feat: contested', '2026-01-01T10:30:00Z')
const tight = makeSession({
sessionId: 'sess-tight',
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
})
const broadLoser = makeSession({ sessionId: 'sess-broad-loser' })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [tight, broadLoser] } as ProjectSummary,
]
const records = computeAttributionRecords(projects, range, repoDir)
const loser = records.find(r => r.sessionId === 'sess-broad-loser')!
// The loser IS emitted (retraction candidate: lostCandidacy) with zero
// commits — the sync layer decides whether a prior state warrants
// actually sending it.
expect(loser).toBeDefined()
expect(loser.commits).toEqual([])
expect(loser.repo).toBe('github.com/acme/widget')
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
it('drops commits when the repo has no remote, but keeps PR-linked sessions', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-noremote-'))
try {
initRepo(repoDir) // no origin remote
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
commitAt(repoDir, 'feat: local only', '2026-01-01T10:30:00Z')
const withPr = makeSession({
sessionId: 'sess-pr',
prLinks: ['https://github.com/acme/widget/pull/7'],
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
})
const withoutPr = makeSession({ sessionId: 'sess-nopr' })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [withPr, withoutPr] } as ProjectSummary,
]
const records = computeAttributionRecords(projects, range, repoDir)
// sess-pr wins the commit window but has no repo identity, so commits
// are dropped; the PR link alone justifies the record. sess-nopr has
// nothing joinable and is omitted.
expect(records).toHaveLength(1)
expect(records[0]!.sessionId).toBe('sess-pr')
expect(records[0]!.repo).toBeNull()
expect(records[0]!.commits).toEqual([])
expect(records[0]!.prLinks).toEqual(['https://github.com/acme/widget/pull/7'])
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
it('never egresses the cwd repo for sessions whose project path did not resolve (fallback)', async () => {
// The reviewer's repro: push from inside a private repo while a session's
// project path no longer resolves. The fallback identity must NOT leak
// the cwd repo's remote or commits into that session's attribution.
const cwdRepo = await mkdtemp(join(tmpdir(), 'codeburn-attr-privatecwd-'))
try {
initRepo(cwdRepo)
git(cwdRepo, ['remote', 'add', 'origin', 'git@github.com:secret-org/nda-client-repo.git'])
await writeFile(join(cwdRepo, 'file.txt'), 'confidential\n')
commitAt(cwdRepo, 'feat: private work', '2026-01-01T10:30:00Z')
// Session A: project path is gone (deleted dir) — falls back to cwd
const orphanNoPr = makeSession({ sessionId: 'orphan-nopr', ...{ firstTimestamp: '2026-01-01T10:15:00.000Z', lastTimestamp: '2026-01-01T10:45:00.000Z' } })
// Session B: also fallback, but carries a PR link (session-native, safe)
const orphanWithPr = makeSession({
sessionId: 'orphan-pr',
prLinks: ['https://github.com/acme/widget/pull/9'],
firstTimestamp: '2026-01-01T12:00:00.000Z',
lastTimestamp: '2026-01-01T12:30:00.000Z',
})
// Session C: genuinely belongs to the cwd repo (own path resolves)
const genuine = makeSession({ sessionId: 'genuine-cwd', firstTimestamp: '2026-01-01T10:00:00.000Z', lastTimestamp: '2026-01-01T11:00:00.000Z' })
const projects = [
{ project: 'ghost', projectPath: join(cwdRepo, 'no-such-dir-anymore-xyz'), sessions: [orphanNoPr] },
{ project: 'ghost2', projectPath: '', sessions: [orphanWithPr] },
{ project: 'real', projectPath: cwdRepo, sessions: [genuine] },
] as ProjectSummary[]
const records = computeAttributionRecords(projects, range, cwdRepo)
// orphan-nopr: nothing joinable -> no record at all
expect(records.find(r => r.sessionId === 'orphan-nopr')).toBeUndefined()
// orphan-pr: PR link only — no repo, no commits
const pr = records.find(r => r.sessionId === 'orphan-pr')!
expect(pr.repo).toBeNull()
expect(pr.commits).toEqual([])
// genuine cwd session keeps full attribution
const own = records.find(r => r.sessionId === 'genuine-cwd')!
expect(own.repo).toBe('github.com/secret-org/nda-client-repo')
expect(own.commits).toHaveLength(1)
// The private repo identity appears ONLY on the genuine record
const leaked = records.filter(r => r.sessionId !== 'genuine-cwd' && JSON.stringify(r).includes('secret-org'))
expect(leaked).toEqual([])
} finally {
await rm(cwdRepo, { recursive: true, force: true })
}
})
it('fallback sessions cannot steal a commit from a genuine session', async () => {
const cwdRepo = await mkdtemp(join(tmpdir(), 'codeburn-attr-steal-'))
try {
initRepo(cwdRepo)
git(cwdRepo, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
await writeFile(join(cwdRepo, 'file.txt'), 'x\n')
commitAt(cwdRepo, 'feat: mine', '2026-01-01T10:30:00Z')
// Fallback session has the TIGHTER window (would win under old logic);
// genuine session has the broader window.
const fallbackTight = makeSession({
sessionId: 'fallback-tight',
prLinks: ['https://github.com/acme/widget/pull/2'],
firstTimestamp: '2026-01-01T10:25:00.000Z',
lastTimestamp: '2026-01-01T10:35:00.000Z',
})
const genuineBroad = makeSession({ sessionId: 'genuine-broad' })
const projects = [
{ project: 'ghost', projectPath: '', sessions: [fallbackTight] },
{ project: 'real', projectPath: cwdRepo, sessions: [genuineBroad] },
] as ProjectSummary[]
const records = computeAttributionRecords(projects, range, cwdRepo)
expect(records.find(r => r.sessionId === 'fallback-tight')!.commits).toEqual([])
expect(records.find(r => r.sessionId === 'genuine-broad')!.commits).toHaveLength(1)
} finally {
await rm(cwdRepo, { recursive: true, force: true })
}
})
it('awards each commit to a single session (tightest window)', async () => {
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-overlap-'))
try {
initRepo(repoDir)
git(repoDir, ['remote', 'add', 'origin', 'https://github.com/acme/widget.git'])
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
commitAt(repoDir, 'feat: shared window', '2026-01-01T10:30:00Z')
const tight = makeSession({
sessionId: 'sess-tight',
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
})
const broad = makeSession({ sessionId: 'sess-broad' })
const projects = [
{ project: 'app', projectPath: repoDir, sessions: [tight, broad] } as ProjectSummary,
]
const records = computeAttributionRecords(projects, range, repoDir)
// Both sessions get records (the loser is a retraction candidate),
// but the commit is awarded exactly once — to the tighter window.
expect(records).toHaveLength(2)
const tightRecord = records.find(r => r.sessionId === 'sess-tight')!
const broadRecord = records.find(r => r.sessionId === 'sess-broad')!
expect(tightRecord.commits).toHaveLength(1)
expect(broadRecord.commits).toEqual([])
} finally {
await rm(repoDir, { recursive: true, force: true })
}
})
})
// ── Dedup keys and flattening ─────────────────────────────────────────
function makeRecord(overrides: Partial<SessionAttributionRecord> = {}): SessionAttributionRecord {
return {
sessionId: 'sess-1',
project: 'app',
repo: 'github.com/acme/widget',
prLinks: ['https://github.com/acme/widget/pull/3'],
commits: [
{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false },
],
firstTimestamp: '2026-01-01T10:00:00.000Z',
lastTimestamp: '2026-01-01T11:00:00.000Z',
...overrides,
}
}
describe('attribution dedup keys', () => {
it('encodes commit state so a state transition mints a new key', () => {
const before = commitAttributionKey('sess-1', 'abc123', false, false)
const merged = commitAttributionKey('sess-1', 'abc123', true, false)
const reverted = commitAttributionKey('sess-1', 'abc123', true, true)
expect(new Set([before, merged, reverted]).size).toBe(3)
// Same state = same key (ledger dedupes repeats)
expect(commitAttributionKey('sess-1', 'abc123', true, false)).toBe(merged)
})
it('session key is stable for identical state and changes with commit state', () => {
const record = makeRecord()
expect(sessionAttributionKey(record)).toBe(sessionAttributionKey(makeRecord()))
const mutated = makeRecord({
commits: [{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: true }],
})
expect(sessionAttributionKey(mutated)).not.toBe(sessionAttributionKey(record))
})
it('session key changes when the window or project changes (ongoing sessions re-emit)', () => {
const base = makeRecord()
const grown = makeRecord({ lastTimestamp: '2026-01-01T12:00:00.000Z' })
const renamed = makeRecord({ project: 'app-renamed' })
expect(sessionAttributionKey(grown)).not.toBe(sessionAttributionKey(base))
expect(sessionAttributionKey(renamed)).not.toBe(sessionAttributionKey(base))
})
it('flattens one session item plus one item per commit', () => {
const items = flattenAttributionRecords([makeRecord()])
expect(items).toHaveLength(2)
expect(items[0]).toMatchObject({ kind: 'session', commitCount: 1, endTimestamp: '2026-01-01T11:00:00.000Z' })
expect(items[1]).toMatchObject({ kind: 'commit', sha: 'a'.repeat(40), inMain: true, wasReverted: false })
expect(items.map(i => i.dedupKey)).toEqual([
sessionAttributionKey(makeRecord()),
commitAttributionKey('sess-1', 'a'.repeat(40), true, false),
])
})
})
// ── PR link sanitization ──────────────────────────────────────────────
describe('sanitizePrLinks', () => {
it('keeps only https URLs shaped like org/repo/pull/N', () => {
expect(sanitizePrLinks([
'https://github.com/acme/widget/pull/12',
'https://ghe.corp.example.com/team/svc/pull/3', // GHE hosts allowed
'http://github.com/acme/widget/pull/12', // not https
'javascript:alert(1)', // not a URL shape we accept
'https://github.com/acme/widget/issues/12', // not a PR path
'https://github.com/acme/widget/pull/12/files', // extra path segment
'not a url at all',
'',
'https://github.com/acme/widget/pull/notanumber',
])).toEqual([
'https://ghe.corp.example.com/team/svc/pull/3',
'https://github.com/acme/widget/pull/12',
])
})
it('rebuilds links from origin + pathname: userinfo, query, and fragment never survive', () => {
expect(sanitizePrLinks([
'https://alice:ghp_TOKEN@github.com/acme/widget/pull/5',
'https://github.com/acme/widget/pull/7?notification_referrer_id=xyz',
'https://github.com/acme/widget/pull/6#pullrequestreview-123',
])).toEqual([
'https://github.com/acme/widget/pull/5',
'https://github.com/acme/widget/pull/6',
'https://github.com/acme/widget/pull/7',
])
})
it('dedupes links that collapse to the same rebuilt URL', () => {
expect(sanitizePrLinks([
'https://github.com/acme/widget/pull/9',
'https://github.com/acme/widget/pull/9?ref=a',
'https://github.com/acme/widget/pull/9#comment',
])).toEqual(['https://github.com/acme/widget/pull/9'])
})
it('drops oversized inputs and caps the count per session', () => {
// A long referrer query is stripped, so the link survives...
const longQuery = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(300)}`
expect(sanitizePrLinks([longQuery])).toEqual(['https://github.com/acme/widget/pull/1'])
// ...but pathological inputs beyond the input bound are dropped outright
const huge = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(600)}`
expect(sanitizePrLinks([huge])).toEqual([])
// And a rebuilt link that is itself oversized is dropped
const longPath = `https://github.com/${'o'.repeat(150)}/${'r'.repeat(80)}/pull/1`
expect(sanitizePrLinks([longPath])).toEqual([])
const many = Array.from({ length: 30 }, (_, i) => `https://github.com/acme/widget/pull/${i + 1}`)
expect(sanitizePrLinks(many)).toHaveLength(MAX_PR_LINKS_PER_SESSION)
})
})
// ── OTLP payload ──────────────────────────────────────────────────────
function attrMap(attributes: OtlpAttribute[]): Record<string, unknown> {
return Object.fromEntries(attributes.map(a => [a.key, a.value]))
}
describe('buildAttributionOtlpPayload', () => {
it('builds session and commit spans sharing the session traceId', async () => {
const items = flattenAttributionRecords([makeRecord()])
const payload = buildAttributionOtlpPayload(items)
const resource = payload.resourceSpans[0]!
const resourceAttrs = attrMap(resource.resource.attributes)
expect(resourceAttrs['codeburn.attribution_methodology']).toEqual({ stringValue: 'timestamp-window' })
expect(resourceAttrs['codeburn.device_id']).toBeDefined()
const spans = resource.scopeSpans[0]!.spans
expect(spans).toHaveLength(2)
const sessionSpan = spans.find(s => s.name === SESSION_ATTRIBUTION_SPAN_NAME)!
const commitSpan = spans.find(s => s.name === COMMIT_ATTRIBUTION_SPAN_NAME)!
// Expected traceId computed under the same persisted key the payload
// builder obtains (getPersistedHostPrivacyKey), never a second key source.
const { getPersistedHostPrivacyKey } = await import('../src/privacy-key.js')
expect(sessionSpan.traceId).toBe(deriveTraceId(getPersistedHostPrivacyKey(), 'sess-1'))
expect(commitSpan.traceId).toBe(sessionSpan.traceId)
expect(sessionSpan.spanId).not.toBe(commitSpan.spanId)
const sessionAttrs = attrMap(sessionSpan.attributes)
expect(sessionAttrs['ai.session_id']).toEqual({ stringValue: 'sess-1' })
expect(sessionAttrs['ai.project']).toEqual({ stringValue: 'app' })
expect(sessionAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' })
expect(sessionAttrs['git.commit_count']).toEqual({ intValue: '1' })
expect(sessionAttrs['git.pr_links']).toEqual({
arrayValue: { values: [{ stringValue: 'https://github.com/acme/widget/pull/3' }] },
})
// Session span carries the real window as its duration
expect(sessionSpan.startTimeUnixNano).toBe((BigInt(new Date('2026-01-01T10:00:00.000Z').getTime()) * 1_000_000n).toString())
expect(sessionSpan.endTimeUnixNano).toBe((BigInt(new Date('2026-01-01T11:00:00.000Z').getTime()) * 1_000_000n).toString())
const commitAttrs = attrMap(commitSpan.attributes)
expect(commitAttrs['git.sha']).toEqual({ stringValue: 'a'.repeat(40) })
expect(commitAttrs['git.in_main']).toEqual({ boolValue: true })
expect(commitAttrs['git.was_reverted']).toEqual({ boolValue: false })
expect(commitAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' })
})
it('omits git.repo when null and pr_links when empty', () => {
const items = flattenAttributionRecords([makeRecord({ repo: null, prLinks: [], commits: [] })])
const payload = buildAttributionOtlpPayload(items)
const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans
expect(spans).toHaveLength(1)
const attrs = attrMap(spans[0]!.attributes)
expect(attrs['git.repo']).toBeUndefined()
expect(attrs['git.pr_links']).toBeUndefined()
expect(attrs['git.commit_count']).toEqual({ intValue: '0' })
})
it('batches items by maxBatchSize', () => {
const items = flattenAttributionRecords([makeRecord(), makeRecord({ sessionId: 'sess-2' })])
expect(batchAttributionItems(items, 3).map(b => b.length)).toEqual([3, 1])
})
it('clamps span end time: never 0, never earlier than start + 1ms', () => {
// Session window ends BEFORE it starts (out-of-order provider timestamps)
const outOfOrder = flattenAttributionRecords([makeRecord({
firstTimestamp: '2026-01-01T11:00:00.000Z',
lastTimestamp: '2026-01-01T10:00:00.000Z',
})])
const span1 = buildAttributionOtlpPayload(outOfOrder).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
expect(BigInt(span1.endTimeUnixNano)).toBe(BigInt(span1.startTimeUnixNano) + 1_000_000n)
// Malformed end timestamp (toUnixNano -> 0)
const malformed = flattenAttributionRecords([makeRecord({ lastTimestamp: 'not-a-date' })])
const span2 = buildAttributionOtlpPayload(malformed).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
expect(span2.endTimeUnixNano).not.toBe('0')
expect(BigInt(span2.endTimeUnixNano)).toBe(BigInt(span2.startTimeUnixNano) + 1_000_000n)
})
})
// ── Send + ledger pipeline ────────────────────────────────────────────
type MockResponse = { status: number; body?: unknown; headers?: Record<string, string> }
function startMockOtlp(responses: MockResponse[]): Promise<{
url: string
server: Server
requests: Array<{ auth: string | undefined; body: unknown }>
}> {
const requests: Array<{ auth: string | undefined; body: unknown }> = []
let idx = 0
return new Promise(resolve => {
const server = createServer((req, res) => {
let raw = ''
req.on('data', c => { raw += c })
req.on('end', () => {
requests.push({ auth: req.headers.authorization, body: JSON.parse(raw || '{}') })
const r = responses[Math.min(idx, responses.length - 1)]!
idx++
res.writeHead(r.status, { 'Content-Type': 'application/json', ...r.headers })
res.end(r.body !== undefined ? JSON.stringify(r.body) : '{}')
})
})
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as { port: number }
resolve({ url: `http://127.0.0.1:${addr.port}/v1/traces`, server, requests })
})
})
}
let tmpDir: string
const originalHome = process.env.HOME
const originalXdgCache = process.env.XDG_CACHE_HOME
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-push-'))
process.env.HOME = tmpDir
process.env.XDG_CACHE_HOME = join(tmpDir, '.cache')
})
afterEach(async () => {
process.env.HOME = originalHome
if (originalXdgCache === undefined) delete process.env.XDG_CACHE_HOME
else process.env.XDG_CACHE_HOME = originalXdgCache
await rm(tmpDir, { recursive: true, force: true })
})
describe('sendAttributionBatches + collectUnsentAttribution', () => {
it('sends attribution spans, ledgers dedup keys, and filters them on the next collect', async () => {
const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js')
const { readLedger } = await import('../src/sync/ledger.js')
const record = makeRecord()
const first = collectUnsentAttribution([record])
expect(first.unsent).toHaveLength(2)
const mock = await startMockOtlp([{ status: 200 }])
try {
const result = await sendAttributionBatches({
endpoint: mock.url,
accessToken: 'token-1',
batches: [first.unsent],
})
expect(result.outcome).toBe('complete')
expect(result.totalSent).toBe(2)
expect(result.totalCostSent).toBe(0)
expect(mock.requests).toHaveLength(1)
expect(mock.requests[0]!.auth).toBe('Bearer token-1')
const body = mock.requests[0]!.body as { resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> }
const names = body.resourceSpans[0]!.scopeSpans[0]!.spans.map(s => s.name).sort()
expect(names).toEqual([COMMIT_ATTRIBUTION_SPAN_NAME, SESSION_ATTRIBUTION_SPAN_NAME])
const ledgered = readLedger().map(e => e.key).sort()
expect(ledgered).toEqual(first.unsent.map(i => i.dedupKey).sort())
// Identical state on the next push: nothing unsent
expect(collectUnsentAttribution([record]).unsent).toEqual([])
// State transition (commit reverted): the changed facts re-send
const mutated = makeRecord({
commits: [{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: true }],
})
const after = collectUnsentAttribution([mutated])
expect(after.unsent.map(i => i.kind).sort()).toEqual(['commit', 'session'])
} finally {
mock.server.close()
}
})
it('retracts a session span when its commit migrates to a tighter-window session', async () => {
const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js')
const sha = 'b'.repeat(40)
const commit = { sha, timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false }
// Push 1: session A (broad window) owns the commit
const push1 = collectUnsentAttribution([makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [commit] })])
expect(push1.unsent).toHaveLength(2)
const mock = await startMockOtlp([{ status: 200 }])
try {
await sendAttributionBatches({ endpoint: mock.url, accessToken: 't', batches: [push1.unsent] })
// Push 2: a later-parsed tighter session B now wins the commit; A is empty
const push2 = collectUnsentAttribution([
makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [] }), // loser: retraction candidate
makeRecord({ sessionId: 'sess-B', prLinks: [], commits: [commit] }), // winner
])
// A re-emits with commit_count 0 (retraction), B emits session + commit
const kinds = push2.unsent.map(i => `${i.sessionId}:${i.kind}`).sort()
expect(kinds).toEqual(['sess-A:session', 'sess-B:commit', 'sess-B:session'])
const retraction = push2.unsent.find(i => i.sessionId === 'sess-A')!
expect(retraction.commitCount).toBe(0)
expect(retraction.dedupKey).not.toBe(push1.unsent.find(i => i.kind === 'session')!.dedupKey)
// A session that was NEVER sent stays excluded when empty
const neverSent = collectUnsentAttribution([
makeRecord({ sessionId: 'sess-never', prLinks: [], commits: [] }),
])
expect(neverSent.unsent).toEqual([])
} finally {
mock.server.close()
}
})
it('does not ledger on server error', async () => {
const { sendAttributionBatches } = await import('../src/sync/push.js')
const { readLedger } = await import('../src/sync/ledger.js')
const items = flattenAttributionRecords([makeRecord()])
const mock = await startMockOtlp([{ status: 500 }])
try {
const result = await sendAttributionBatches({
endpoint: mock.url,
accessToken: 'token-1',
batches: [items],
})
expect(result.outcome).toBe('server-error')
expect(readLedger()).toEqual([])
} finally {
mock.server.close()
}
})
})