mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 11:44:39 +00:00
os.homedir() reads USERPROFILE on Windows and never HOME, so the isolation setup and every test that moved home were still resolving the developer's real home directory. Fixtures came back empty, real sessions leaked into counts, and the code under test wrote plans, receipts and a sync ledger into the real ~/.config/codeburn during a run. USERPROFILE now joins the redirected variables and a shared setHome() helper moves HOME and USERPROFILE together; every spawn env carries both. The remaining Windows-only failures were POSIX-shaped fixtures: chmod tricks that only toggle a read-only attribute on NTFS, pid 1 as an always-alive lock holder, splitting paths on '/', bare tar, mode-bit expectations, and per-file timeouts too short for the cost of a tsx spawn. Each gets a platform-aware equivalent so nothing is skipped on Windows and the POSIX branches stay byte-for-byte what they were. Full run on Windows: 267 files passed, 2 skipped, 3592 tests passed.
813 lines
36 KiB
TypeScript
813 lines
36 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'
|
|
import { setHome } from './setup/home.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',
|
|
workingDirectory: repoDir,
|
|
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', workingDirectory: repoDir })
|
|
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',
|
|
workingDirectory: repoDir,
|
|
firstTimestamp: '2026-01-01T10:15:00.000Z',
|
|
lastTimestamp: '2026-01-01T10:45:00.000Z',
|
|
})
|
|
const broadLoser = makeSession({ sessionId: 'sess-broad-loser', workingDirectory: repoDir })
|
|
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',
|
|
workingDirectory: repoDir,
|
|
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', workingDirectory: repoDir })
|
|
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', workingDirectory: cwdRepo, 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', workingDirectory: cwdRepo })
|
|
|
|
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',
|
|
workingDirectory: repoDir,
|
|
firstTimestamp: '2026-01-01T10:15:00.000Z',
|
|
lastTimestamp: '2026-01-01T10:45:00.000Z',
|
|
})
|
|
const broad = makeSession({ sessionId: 'sess-broad', workingDirectory: repoDir })
|
|
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 })
|
|
}
|
|
})
|
|
|
|
it('never treats a valid but unproven projectPath as attribution provenance', async () => {
|
|
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-untrusted-path-'))
|
|
try {
|
|
initRepo(repoDir)
|
|
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:secret-org/storage-profile.git'])
|
|
await writeFile(join(repoDir, 'file.txt'), 'x\n')
|
|
commitAt(repoDir, 'feat: unrelated storage commit', '2026-01-01T10:30:00Z')
|
|
const session = makeSession({
|
|
sessionId: 'untrusted-project-path',
|
|
prLinks: ['https://github.com/acme/widget/pull/9'],
|
|
})
|
|
const projects = [{ project: 'profile', projectPath: repoDir, sessions: [session] }] as ProjectSummary[]
|
|
|
|
const records = computeAttributionRecords(projects, range, repoDir)
|
|
expect(records).toHaveLength(1)
|
|
expect(records[0]).toMatchObject({ repo: null, commits: [] })
|
|
expect(JSON.stringify(records)).not.toContain('secret-org')
|
|
} finally {
|
|
await rm(repoDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('never resolves a relative workingDirectory against the sync cwd', async () => {
|
|
const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-relative-cwd-'))
|
|
try {
|
|
initRepo(repoDir)
|
|
git(repoDir, ['remote', 'add', 'origin', 'git@github.com:secret-org/current-repo.git'])
|
|
const session = makeSession({
|
|
sessionId: 'relative-cwd',
|
|
workingDirectory: '.',
|
|
prLinks: ['https://github.com/acme/widget/pull/10'],
|
|
})
|
|
const projects = [{ project: 'local-label', projectPath: repoDir, sessions: [session] }] as ProjectSummary[]
|
|
|
|
const records = computeAttributionRecords(projects, range, repoDir)
|
|
expect(records).toHaveLength(1)
|
|
expect(records[0]).toMatchObject({ repo: null, commits: [] })
|
|
expect(JSON.stringify(records)).not.toContain('secret-org')
|
|
} 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', () => {
|
|
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)!
|
|
expect(sessionSpan.traceId).toBe(deriveTraceId('sess-1'))
|
|
expect(commitSpan.traceId).toBe(deriveTraceId('sess-1'))
|
|
expect(sessionSpan.spanId).not.toBe(commitSpan.spanId)
|
|
|
|
const sessionAttrs = attrMap(sessionSpan.attributes)
|
|
expect(sessionAttrs['ai.session_id']).toBeUndefined()
|
|
expect(sessionAttrs['ai.project']).toEqual({ stringValue: 'widget' })
|
|
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['ai.session_id']).toBeUndefined()
|
|
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('keeps session identity off the wire; join is the shared traceId', () => {
|
|
const items = flattenAttributionRecords([makeRecord()])
|
|
const payload = buildAttributionOtlpPayload(items)
|
|
const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans
|
|
expect(spans.length).toBeGreaterThan(0)
|
|
for (const span of spans) {
|
|
expect(attrMap(span.attributes)['ai.session_id']).toBeUndefined()
|
|
expect(span.traceId).toBe(deriveTraceId('sess-1'))
|
|
}
|
|
})
|
|
|
|
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-'))
|
|
setHome(tmpDir)
|
|
process.env.XDG_CACHE_HOME = join(tmpDir, '.cache')
|
|
})
|
|
|
|
afterEach(async () => {
|
|
setHome(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()
|
|
}
|
|
})
|
|
})
|