diff --git a/services/sla_enforcement/README.md b/services/sla_enforcement/README.md index dbeb9e0..93eb39b 100644 --- a/services/sla_enforcement/README.md +++ b/services/sla_enforcement/README.md @@ -15,6 +15,7 @@ A configurable Linear agent that protects issues from unauthorized changes. Auto |---------|-------------| | **Protected Labels** | Configure any labels (e.g., "Vulnerability", "Security Critical") that cannot be removed by unauthorized users | | **SLA Protection** | Monitors all 5 SLA fields: type, start date, medium risk, high risk, breach date | +| **SLA Created-At Baseline** | Enforces that `slaStartedAt` always equals the issue's creation date — catches silent resets from priority changes or workflow triggers | | **Priority Protection** | Prevent unauthorized priority changes (Urgent, High, Normal, Low) | | **Label Hierarchy** | Detects labels in both top-level and label groups | | **Allowlist** | Define authorized users by email or Linear user ID | @@ -70,7 +71,8 @@ Edit `config/config.json`: "protectedFields": { "label": true, "sla": true, - "priority": true + "priority": true, + "slaCreatedAtBaseline": false }, "allowlist": [ { "email": "security-lead@yourcompany.com", "name": "Security Lead" }, @@ -151,13 +153,28 @@ Add any label names you want to protect. Case-sensitive. "protectedFields": { "label": true, "sla": true, - "priority": true + "priority": true, + "slaCreatedAtBaseline": false } } ``` Set individual fields to `false` to disable protection for that field type. +#### `slaCreatedAtBaseline` + +When set to `true`, the agent enforces that `slaStartedAt` always equals the issue's `createdAt` (the date the issue was created in Linear). This is the strictest form of SLA clock protection. + +**Why this matters:** Linear can silently reset `slaStartedAt` when other fields change — for example, changing an issue's priority may trigger a workflow that recalculates and overwrites the SLA start date. The standard `sla` protection only catches changes that appear explicitly in the webhook's `updatedFrom` payload. `slaCreatedAtBaseline` catches *all* drift, including silent resets, by comparing the current `slaStartedAt` against the cached `createdAt` on every webhook. + +**How it works:** +1. On startup, the agent fetches `createdAt` for every issue with a protected label and stores it as an immutable baseline in its cache. +2. On every subsequent webhook for a protected issue, the agent compares `slaStartedAt` against the cached `createdAt`. +3. If they differ and the actor is not in the allowlist, the agent reverts `slaStartedAt` back to `createdAt`. +4. The baseline is never overwritten — even authorized changes do not update the `createdAt` target. + +**When to use:** Enable this when you need to guarantee that the SLA clock always reflects the true issue creation date and cannot be gamed by indirect changes (e.g., priority bumps, label swaps, or workflow triggers). + ### Allowlist Users who are authorized to make changes to protected fields: diff --git a/services/sla_enforcement/config/config.json.example b/services/sla_enforcement/config/config.json.example index 0e3dced..e7c70a8 100644 --- a/services/sla_enforcement/config/config.json.example +++ b/services/sla_enforcement/config/config.json.example @@ -7,7 +7,8 @@ "protectedFields": { "label": true, "sla": true, - "priority": true + "priority": true, + "slaCreatedAtBaseline": false }, "allowlist": [ { diff --git a/services/sla_enforcement/src/enforcement-engine.ts b/services/sla_enforcement/src/enforcement-engine.ts index 3f3b06e..c79f21c 100644 --- a/services/sla_enforcement/src/enforcement-engine.ts +++ b/services/sla_enforcement/src/enforcement-engine.ts @@ -26,6 +26,7 @@ interface CacheEntry { slaStartedAt: string | null; slaBreachesAt: string | null; priority?: number; + createdAt: string | null; // immutable baseline — never overwritten after first cache cachedAt: string; // ISO string for JSON serialization } @@ -37,12 +38,13 @@ interface PersistentCache { export class EnforcementEngine { // In-memory cache of SLA and priority states for protected issues // Maps issueId -> { slaType, slaStartedAt, slaBreachesAt, priority, cachedAt } - private slaCache: Map = new Map(); private cacheFilePath: string; @@ -75,6 +77,7 @@ export class EnforcementEngine { slaStartedAt: entry.slaStartedAt, slaBreachesAt: entry.slaBreachesAt, priority: entry.priority, + createdAt: entry.createdAt ?? null, cachedAt: new Date(entry.cachedAt) }); } @@ -119,43 +122,47 @@ export class EnforcementEngine { const issues = await this.linearClient.getIssuesWithLabel(label.id); for (const issue of issues) { - if (issue.slaType && issue.slaStartedAt && issue.slaBreachesAt) { - const existingEntry = this.slaCache.get(issue.id); - - // Update cache if: - // 1. Issue not in cache yet, OR - // 2. Current SLA in Linear is DIFFERENT from cached (Linear was updated) - const shouldUpdate = !existingEntry || + const existingEntry = this.slaCache.get(issue.id); + + // Always cache if issue has slaStartedAt or createdAt — we need + // createdAt as the immutable baseline even when SLA is not fully set. + if (issue.slaType && issue.slaStartedAt && issue.slaBreachesAt || issue.createdAt) { + // Update SLA/priority fields if they changed, but NEVER overwrite createdAt + // once it has been set — it is the immutable baseline. + const shouldUpdate = !existingEntry || existingEntry.slaBreachesAt !== issue.slaBreachesAt || existingEntry.priority !== issue.priority; - + if (shouldUpdate) { this.slaCache.set(issue.id, { - slaType: issue.slaType, - slaStartedAt: issue.slaStartedAt, - slaBreachesAt: issue.slaBreachesAt, + slaType: issue.slaType || null, + slaStartedAt: issue.slaStartedAt || null, + slaBreachesAt: issue.slaBreachesAt || null, priority: issue.priority, + // Preserve existing createdAt if already cached — never overwrite + createdAt: existingEntry?.createdAt ?? issue.createdAt ?? null, cachedAt: new Date() }); cachedCount++; - - logger.info('Cached/updated issue SLA on startup', { + + logger.info('Cached/updated issue on startup', { issueId: issue.id, identifier: issue.identifier, slaType: issue.slaType, slaBreachesAt: issue.slaBreachesAt, + createdAt: issue.createdAt, priority: issue.priority, wasUpdate: !!existingEntry }); } else { - logger.debug('Issue SLA unchanged, keeping cached values', { + logger.debug('Issue unchanged, keeping cached values', { issueId: issue.id, identifier: issue.identifier }); } } else { skippedCount++; - logger.debug('Issue has no SLA, skipping', { + logger.debug('Issue has no SLA or createdAt, skipping', { issueId: issue.id, identifier: issue.identifier }); @@ -202,6 +209,7 @@ export class EnforcementEngine { slaStartedAt: entry.slaStartedAt, slaBreachesAt: entry.slaBreachesAt, priority: entry.priority, + createdAt: entry.createdAt ?? null, cachedAt: entry.cachedAt.toISOString() }; } @@ -538,11 +546,14 @@ export class EnforcementEngine { slaBreachesAt?: string | null; priority?: number; }): void { + // Preserve createdAt — it is the immutable baseline and must never be overwritten + const existing = this.slaCache.get(issueId); this.slaCache.set(issueId, { slaType: values.slaType || null, slaStartedAt: values.slaStartedAt || null, slaBreachesAt: values.slaBreachesAt || null, priority: values.priority, + createdAt: existing?.createdAt ?? null, cachedAt: new Date() }); @@ -809,6 +820,37 @@ export class EnforcementEngine { } } + // Canonical slaStartedAt baseline check + // When enabled, slaStartedAt must always equal the issue's createdAt. + // This catches silent resets (e.g. from priority-triggered workflows) that + // don't appear in updatedFrom and would otherwise go undetected. + if (this.config.protectedFields.slaCreatedAtBaseline) { + const cached = this.slaCache.get(current.id); + if (cached?.createdAt && current.slaStartedAt !== cached.createdAt) { + const alreadyDetected = changes.find(c => c.field === 'slaStartedAt'); + if (alreadyDetected) { + // Override the revert target to createdAt rather than the previous value + alreadyDetected.oldValue = cached.createdAt; + alreadyDetected.description = `SLA start date changed from issue creation date`; + alreadyDetected.revertDescription = `Restored SLA start date to issue creation date (${cached.createdAt})`; + } else { + // Not caught by the updatedFrom check — add it now + changes.push({ + field: 'slaStartedAt', + oldValue: cached.createdAt, + newValue: current.slaStartedAt, + description: `SLA start date (${current.slaStartedAt ?? 'unset'}) differs from issue creation date (${cached.createdAt})`, + revertDescription: `Restored SLA start date to issue creation date (${cached.createdAt})` + }); + logger.info('Canonical baseline: detected slaStartedAt drift', { + issueId: current.id, + currentSlaStartedAt: current.slaStartedAt, + expectedCreatedAt: cached.createdAt + }); + } + } + } + return changes; } @@ -895,11 +937,24 @@ export class EnforcementEngine { } // NO FALLBACK to currentState - that has the wrong (workflow-generated) values! + // When baseline mode is on, always use createdAt as slaStartedAt regardless + // of what previous state or cache says + if (this.config.protectedFields.slaCreatedAtBaseline) { + const cached = this.slaCache.get(issueId); + if (cached?.createdAt) { + slaStartedAt = cached.createdAt; + logger.info('slaCreatedAtBaseline: overriding slaStartedAt with createdAt for priority revert', { + issueId, + createdAt: cached.createdAt + }); + } + } + if (slaType && slaStartedAt && slaBreachesAt) { update.slaType = slaType; update.slaStartedAt = typeof slaStartedAt === 'string' ? new Date(slaStartedAt) : slaStartedAt; update.slaBreachesAt = typeof slaBreachesAt === 'string' ? new Date(slaBreachesAt) : slaBreachesAt; - + logger.info('Restoring SLA to prevent workflow recalculation after priority revert', { issueId, slaType: update.slaType, diff --git a/services/sla_enforcement/src/linear-client.ts b/services/sla_enforcement/src/linear-client.ts index 30cff60..7968a65 100644 --- a/services/sla_enforcement/src/linear-client.ts +++ b/services/sla_enforcement/src/linear-client.ts @@ -490,6 +490,7 @@ export class LinearClient { slaMediumRiskAt slaHighRiskAt slaBreachesAt + createdAt labels { nodes { id @@ -513,6 +514,7 @@ export class LinearClient { slaMediumRiskAt: issue.slaMediumRiskAt || null, slaHighRiskAt: issue.slaHighRiskAt || null, slaBreachesAt: issue.slaBreachesAt || null, + createdAt: issue.createdAt || null, labels: issue.labels?.nodes?.map((l: any) => ({ id: l.id, name: l.name diff --git a/services/sla_enforcement/src/types.ts b/services/sla_enforcement/src/types.ts index 25540b4..dc9dc16 100644 --- a/services/sla_enforcement/src/types.ts +++ b/services/sla_enforcement/src/types.ts @@ -13,6 +13,7 @@ export interface Config { label: boolean; sla: boolean; priority: boolean; + slaCreatedAtBaseline?: boolean; }; allowlist: AllowlistUser[]; agent: AgentConfig; diff --git a/services/sla_enforcement/tests/unit/enforcement-engine.test.ts b/services/sla_enforcement/tests/unit/enforcement-engine.test.ts index ce2c997..e125a7f 100644 --- a/services/sla_enforcement/tests/unit/enforcement-engine.test.ts +++ b/services/sla_enforcement/tests/unit/enforcement-engine.test.ts @@ -2,6 +2,9 @@ * Unit tests for enforcement engine */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { EnforcementEngine } from '../../src/enforcement-engine'; import { LinearClient } from '../../src/linear-client'; import { Config, IssueWebhookPayload, WebhookActor, IssueLabel } from '../../src/types'; @@ -15,12 +18,17 @@ describe('EnforcementEngine', () => { let engine: EnforcementEngine; let mockLinearClient: jest.Mocked; let config: Config; + let outerTmpDir: string; beforeEach(() => { + // Use a fresh temp directory for each test so disk-persisted cache files + // from previous runs never contaminate subsequent tests. + outerTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sla-test-')); + config = { protectedLabels: ['Vulnerability'], checkLabelGroups: true, - protectedFields: { label: true, sla: true }, + protectedFields: { label: true, sla: true, priority: false }, allowlist: [ { email: 'admin@example.com', name: 'Admin User' } ], @@ -39,7 +47,7 @@ describe('EnforcementEngine', () => { logging: { level: 'info', auditTrail: true, - auditLogPath: './logs/test-audit.log' + auditLogPath: path.join(outerTmpDir, 'audit.log') } }; @@ -47,6 +55,10 @@ describe('EnforcementEngine', () => { engine = new EnforcementEngine(config, mockLinearClient); }); + afterEach(() => { + fs.rmSync(outerTmpDir, { recursive: true, force: true }); + }); + describe('Agent action detection', () => { it('should skip enforcement for agent actions (by user ID)', async () => { const payload: IssueWebhookPayload = { @@ -184,10 +196,12 @@ describe('EnforcementEngine', () => { data: { id: 'issue-1', title: 'Test Issue', - labels: [] // Label removed + labels: [], // Label removed + labelIds: [] }, updatedFrom: { - labels: [{ id: 'label-1', name: 'Vulnerability' }] // Had protected label before + labelIds: ['label-1'], // triggers label change detection + labels: [{ id: 'label-1', name: 'Vulnerability' }] // for hadProtectedBefore check }, createdAt: new Date().toISOString(), url: 'https://linear.app/issue/1', @@ -196,17 +210,404 @@ describe('EnforcementEngine', () => { organizationId: 'org-1' }; - // Need to mock getIssue to return issue with protected label - mockLinearClient.getIssue = jest.fn().mockResolvedValue({ - id: 'issue-1', - title: 'Test Issue', - labels: [{ id: 'label-1', name: 'Vulnerability' }] + mockLinearClient.findLabelById = jest.fn().mockResolvedValue({ id: 'label-1', name: 'Vulnerability' }); + mockLinearClient.createComment = jest.fn().mockResolvedValue(undefined); + + const result = await engine.enforce(payload); + + expect(result.enforced).toBe(false); + expect(result.reason).toBe('User authorized'); + }); + }); + + describe('slaCreatedAtBaseline enforcement', () => { + const ISSUE_ID = 'issue-baseline-1'; + const CREATED_AT = '2026-01-01T09:00:00.000Z'; + const DRIFTED_SLA_STARTED_AT = '2026-03-01T09:00:00.000Z'; + const SLA_BREACHES_AT = '2026-04-15T09:00:00.000Z'; + + // Shared baseline actor (unauthorized) + const unauthorizedActor = { + id: 'user-1', + type: 'user' as const, + name: 'Regular User', + email: 'user@example.com', + url: 'https://linear.app/user/user-1' + }; + + // Shared baseline actor (authorized) + const authorizedActor = { + id: 'admin-1', + type: 'user' as const, + name: 'Admin User', + email: 'admin@example.com', + url: 'https://linear.app/user/admin-1' + }; + + // Populates the cache with createdAt so the engine has a baseline to enforce against + async function seedCache(issueId = ISSUE_ID, createdAt = CREATED_AT) { + mockLinearClient.findLabelByName = jest.fn().mockResolvedValue({ + id: 'label-vuln', + name: 'Vulnerability' }); + mockLinearClient.getIssuesWithLabel = jest.fn().mockResolvedValue([{ + id: issueId, + title: 'Test Issue', + identifier: 'SEC-1', + priority: 2, + slaType: 'all', + slaStartedAt: createdAt, + slaBreachesAt: SLA_BREACHES_AT, + createdAt, + labels: [{ id: 'label-vuln', name: 'Vulnerability' }] + }]); + await engine.cacheProtectedIssues(); + } + + let tmpDir: string; + + beforeEach(() => { + // Isolate each test to its own temp directory so disk-persisted cache + // from one test never leaks into another. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sla-test-')); + config.logging.auditLogPath = path.join(tmpDir, 'audit.log'); + config.protectedFields = { label: true, sla: true, priority: true, slaCreatedAtBaseline: true }; + engine = new EnforcementEngine(config, mockLinearClient); + + mockLinearClient.findLabelById = jest.fn().mockResolvedValue(null); + mockLinearClient.createComment = jest.fn().mockResolvedValue(undefined); + mockLinearClient.updateIssue = jest.fn().mockResolvedValue(undefined); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should not enforce when slaStartedAt equals createdAt', async () => { + await seedCache(); + + const payload: IssueWebhookPayload = { + type: 'Issue', + action: 'update', + actor: unauthorizedActor, + data: { + id: ISSUE_ID, + title: 'Test Issue', + labels: [{ id: 'label-vuln', name: 'Vulnerability' }], + slaType: 'all', + slaStartedAt: CREATED_AT, // in sync with baseline — no drift + slaBreachesAt: SLA_BREACHES_AT, + priority: 2 + }, + updatedFrom: { + // title changed only — no SLA, priority, or label fields — no enforceable changes + title: 'Old title' + }, + createdAt: CREATED_AT, + url: '', + webhookTimestamp: Date.now(), + webhookId: 'wh-1', + organizationId: 'org-1' + }; + + const result = await engine.enforce(payload); + + expect(result.enforced).toBe(false); + expect(result.changes?.some(c => c.field === 'slaStartedAt')).toBeFalsy(); + expect(mockLinearClient.updateIssue).not.toHaveBeenCalled(); + }); + + it('should revert slaStartedAt to createdAt when explicitly changed', async () => { + await seedCache(); + + const payload: IssueWebhookPayload = { + type: 'Issue', + action: 'update', + actor: unauthorizedActor, + data: { + id: ISSUE_ID, + title: 'Test Issue', + labels: [{ id: 'label-vuln', name: 'Vulnerability' }], + slaType: 'all', + slaStartedAt: DRIFTED_SLA_STARTED_AT, // manually reset the clock + slaBreachesAt: SLA_BREACHES_AT, + priority: 2 + }, + updatedFrom: { + slaType: 'all', + slaStartedAt: CREATED_AT, // previous value was createdAt + slaBreachesAt: SLA_BREACHES_AT + }, + createdAt: CREATED_AT, + url: '', + webhookTimestamp: Date.now(), + webhookId: 'wh-1', + organizationId: 'org-1' + }; + + const result = await engine.enforce(payload); + + expect(result.enforced).toBe(true); + expect(result.changes?.some(c => c.field === 'slaStartedAt')).toBe(true); + + // slaStartedAt in the update call must be createdAt, not the intermediate value + const updateCalls = (mockLinearClient.updateIssue as jest.Mock).mock.calls; + const slaCall = updateCalls.find(([, input]) => input.slaStartedAt !== undefined); + expect(slaCall).toBeDefined(); + const sent = slaCall[1].slaStartedAt; + expect(sent instanceof Date ? sent.toISOString() : sent).toBe(CREATED_AT); + }); + + it('should detect slaStartedAt drift that is absent from updatedFrom', async () => { + // A priority-triggered workflow silently resets slaStartedAt. + // Linear does NOT include slaStartedAt in updatedFrom in this case — + // the standard check misses it, but the canonical baseline check catches it. + await seedCache(); + + config.behavior.dryRun = true; // use dry run so we can inspect changes without full revert + engine = new EnforcementEngine(config, mockLinearClient); + await seedCache(); + + const payload: IssueWebhookPayload = { + type: 'Issue', + action: 'update', + actor: unauthorizedActor, + data: { + id: ISSUE_ID, + title: 'Test Issue', + labels: [{ id: 'label-vuln', name: 'Vulnerability' }], + slaType: 'all', + slaStartedAt: DRIFTED_SLA_STARTED_AT, // silently reset by workflow + slaBreachesAt: SLA_BREACHES_AT, + priority: 1 + }, + updatedFrom: { priority: 2 }, // only priority in updatedFrom — slaStartedAt absent + createdAt: CREATED_AT, + url: '', + webhookTimestamp: Date.now(), + webhookId: 'wh-1', + organizationId: 'org-1' + }; + + const result = await engine.enforce(payload); + + expect(result.dryRun).toBe(true); + const slaStartChange = result.changes?.find(c => c.field === 'slaStartedAt'); + expect(slaStartChange).toBeDefined(); + expect(slaStartChange?.oldValue).toBe(CREATED_AT); // revert target is createdAt + expect(slaStartChange?.newValue).toBe(DRIFTED_SLA_STARTED_AT); + expect(mockLinearClient.updateIssue).not.toHaveBeenCalled(); + }); + + it('should use createdAt as slaStartedAt in the two-step priority revert', async () => { + // Priority change + SLA fields in updatedFrom triggers the two-step update. + // The second call (SLA restore) must use createdAt as slaStartedAt. + jest.useFakeTimers(); + await seedCache(); + + const PREV_BREACHES_AT = '2026-03-01T09:00:00.000Z'; + + const payload: IssueWebhookPayload = { + type: 'Issue', + action: 'update', + actor: unauthorizedActor, + data: { + id: ISSUE_ID, + title: 'Test Issue', + labels: [{ id: 'label-vuln', name: 'Vulnerability' }], + slaType: 'all', + slaStartedAt: DRIFTED_SLA_STARTED_AT, // reset by workflow after priority change + slaBreachesAt: SLA_BREACHES_AT, // recalculated after reset + priority: 1 // changed from 2 + }, + updatedFrom: { + priority: 2, + slaType: 'all', + slaStartedAt: '2025-12-01T00:00:00.000Z', // some intermediate — NOT createdAt + slaBreachesAt: PREV_BREACHES_AT + }, + createdAt: CREATED_AT, + url: '', + webhookTimestamp: Date.now(), + webhookId: 'wh-1', + organizationId: 'org-1' + }; + + const enforcePromise = engine.enforce(payload); + await jest.runAllTimersAsync(); + const result = await enforcePromise; + + jest.useRealTimers(); + + expect(result.enforced).toBe(true); + expect(mockLinearClient.updateIssue).toHaveBeenCalledTimes(2); + + // Second call restores SLA — slaStartedAt must be createdAt + const slaRestoreCall = (mockLinearClient.updateIssue as jest.Mock).mock.calls[1]; + const sentSlaStartedAt = slaRestoreCall[1].slaStartedAt; + const isoValue = sentSlaStartedAt instanceof Date + ? sentSlaStartedAt.toISOString() + : sentSlaStartedAt; + expect(isoValue).toBe(CREATED_AT); + }); + + it('should allow slaStartedAt changes from authorized users without reverting', async () => { + await seedCache(); + + const payload: IssueWebhookPayload = { + type: 'Issue', + action: 'update', + actor: authorizedActor, // in allowlist + data: { + id: ISSUE_ID, + title: 'Test Issue', + labels: [{ id: 'label-vuln', name: 'Vulnerability' }], + slaType: 'all', + slaStartedAt: DRIFTED_SLA_STARTED_AT, + slaBreachesAt: SLA_BREACHES_AT, + priority: 2 + }, + updatedFrom: { + slaType: 'all', + slaStartedAt: CREATED_AT, + slaBreachesAt: SLA_BREACHES_AT + }, + createdAt: CREATED_AT, + url: '', + webhookTimestamp: Date.now(), + webhookId: 'wh-1', + organizationId: 'org-1' + }; const result = await engine.enforce(payload); expect(result.enforced).toBe(false); expect(result.reason).toBe('User authorized'); + expect(mockLinearClient.updateIssue).not.toHaveBeenCalled(); + }); + + it('should never overwrite createdAt baseline even after an authorized change', async () => { + await seedCache(); + + // Authorized user changes slaStartedAt — cache updates, but createdAt stays fixed + const authorizedPayload: IssueWebhookPayload = { + type: 'Issue', + action: 'update', + actor: authorizedActor, + data: { + id: ISSUE_ID, + title: 'Test Issue', + labels: [{ id: 'label-vuln', name: 'Vulnerability' }], + slaType: 'all', + slaStartedAt: DRIFTED_SLA_STARTED_AT, + slaBreachesAt: SLA_BREACHES_AT, + priority: 2 + }, + updatedFrom: { + slaType: 'all', + slaStartedAt: CREATED_AT, + slaBreachesAt: SLA_BREACHES_AT + }, + createdAt: CREATED_AT, + url: '', + webhookTimestamp: Date.now(), + webhookId: 'wh-admin', + organizationId: 'org-1' + }; + await engine.enforce(authorizedPayload); + + // Now an unauthorized user touches the issue — baseline must still be CREATED_AT + const unauthorizedPayload: IssueWebhookPayload = { + type: 'Issue', + action: 'update', + actor: unauthorizedActor, + data: { + id: ISSUE_ID, + title: 'Test Issue', + labels: [{ id: 'label-vuln', name: 'Vulnerability' }], + slaType: 'all', + slaStartedAt: DRIFTED_SLA_STARTED_AT, // still drifted from createdAt + slaBreachesAt: SLA_BREACHES_AT, + priority: 2 + }, + updatedFrom: { + slaType: 'all', + slaStartedAt: DRIFTED_SLA_STARTED_AT, + slaBreachesAt: SLA_BREACHES_AT + }, + createdAt: CREATED_AT, + url: '', + webhookTimestamp: Date.now(), + webhookId: 'wh-user', + organizationId: 'org-1' + }; + const result = await engine.enforce(unauthorizedPayload); + + const slaStartChange = result.changes?.find(c => c.field === 'slaStartedAt'); + // Baseline is still CREATED_AT — the authorized change did not overwrite it + expect(slaStartChange?.oldValue).toBe(CREATED_AT); + }); + + it('should skip baseline check gracefully when createdAt is not in cache', async () => { + // No seedCache() call — simulates an issue the engine has never seen + const payload: IssueWebhookPayload = { + type: 'Issue', + action: 'update', + actor: unauthorizedActor, + data: { + id: ISSUE_ID, + title: 'Test Issue', + labels: [{ id: 'label-vuln', name: 'Vulnerability' }], + slaType: 'all', + slaStartedAt: DRIFTED_SLA_STARTED_AT, + slaBreachesAt: SLA_BREACHES_AT, + priority: 2 + }, + updatedFrom: {}, // nothing changed explicitly + createdAt: CREATED_AT, + url: '', + webhookTimestamp: Date.now(), + webhookId: 'wh-1', + organizationId: 'org-1' + }; + + // Should not throw — baseline check should be silently skipped + await expect(engine.enforce(payload)).resolves.not.toThrow(); + expect(mockLinearClient.updateIssue).not.toHaveBeenCalled(); + }); + + it('should not enforce canonical check when slaCreatedAtBaseline is false', async () => { + config.protectedFields = { label: true, sla: false, priority: false, slaCreatedAtBaseline: false }; + engine = new EnforcementEngine(config, mockLinearClient); + await seedCache(); + + const payload: IssueWebhookPayload = { + type: 'Issue', + action: 'update', + actor: unauthorizedActor, + data: { + id: ISSUE_ID, + title: 'Test Issue', + labels: [{ id: 'label-vuln', name: 'Vulnerability' }], + slaType: 'all', + slaStartedAt: DRIFTED_SLA_STARTED_AT, // drifted — but feature is off + slaBreachesAt: SLA_BREACHES_AT, + priority: 2 + }, + updatedFrom: { + slaStartedAt: CREATED_AT + }, + createdAt: CREATED_AT, + url: '', + webhookTimestamp: Date.now(), + webhookId: 'wh-1', + organizationId: 'org-1' + }; + + const result = await engine.enforce(payload); + + expect(result.enforced).toBe(false); + expect(mockLinearClient.updateIssue).not.toHaveBeenCalled(); }); }); @@ -228,10 +629,12 @@ describe('EnforcementEngine', () => { data: { id: 'issue-1', title: 'Test Issue', - labels: [] // Label removed + labels: [], // Label removed + labelIds: [] }, updatedFrom: { - labels: [{ id: 'label-1', name: 'Vulnerability' }] // Had protected label before + labelIds: ['label-1'], // triggers label change detection + labels: [{ id: 'label-1', name: 'Vulnerability' }] // for hadProtectedBefore check }, createdAt: new Date().toISOString(), url: 'https://linear.app/issue/1', @@ -240,12 +643,7 @@ describe('EnforcementEngine', () => { organizationId: 'org-1' }; - // Need to mock getIssue to return issue with protected label - mockLinearClient.getIssue = jest.fn().mockResolvedValue({ - id: 'issue-1', - title: 'Test Issue', - labels: [{ id: 'label-1', name: 'Vulnerability' }] - }); + mockLinearClient.findLabelById = jest.fn().mockResolvedValue({ id: 'label-1', name: 'Vulnerability' }); const result = await engine.enforce(payload);