Skip to content

Commit de805ff

Browse files
authored
fix: add escapeHtml XSS tests and consolidate duplicate test factories (#510)
Add 14 security-focused regression tests for escapeHtml() covering all HTML entity escapes, XSS vectors (script injection, event handlers, javascript: protocol), double-escaping, and realistic PR title content. Consolidate 4 duplicate makePR() factory functions across daily.test.ts, daily-orchestration.test.ts, and pr-monitor.test.ts to use the shared makeFetchedPR() from test-utils.ts with thin semantic wrappers. Closes #486
1 parent fa3192c commit de805ff

File tree

4 files changed

+114
-117
lines changed

4 files changed

+114
-117
lines changed

packages/core/src/commands/daily-orchestration.test.ts

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1010
import type { FetchedPR, DailyDigest, CommentedIssue } from '../core/types.js';
11+
import { makeFetchedPR } from '../core/test-utils.js';
1112
import type { FetchPRsResult } from '../core/pr-monitor.js';
1213

1314
// ---------------------------------------------------------------------------
@@ -98,30 +99,9 @@ import { requireGitHubToken } from '../core/index.js';
9899
// Test helpers
99100
// ---------------------------------------------------------------------------
100101

101-
/** Create a minimal FetchedPR for orchestration testing */
102-
function makePR(overrides: Partial<FetchedPR> & { repo: string; number?: number }): FetchedPR {
103-
const num = overrides.number ?? 1;
104-
return {
105-
id: num,
106-
url: `https://github.com/${overrides.repo}/pull/${num}`,
107-
number: num,
108-
title: 'Test PR',
109-
createdAt: '2026-01-01T00:00:00Z',
110-
updatedAt: '2026-01-15T00:00:00Z',
111-
daysSinceActivity: 5,
112-
ciStatus: 'passing',
113-
failingCheckNames: [],
114-
classifiedChecks: [],
115-
hasMergeConflict: false,
116-
reviewDecision: 'approved',
117-
hasUnrespondedComment: false,
118-
hasIncompleteChecklist: false,
119-
maintainerActionHints: [],
120-
status: 'healthy',
121-
displayLabel: '[Healthy]',
122-
displayDescription: 'Everything looks good — normal review cycle',
123-
...overrides,
124-
};
102+
/** Create a FetchedPR with repo as required (mirrors orchestration's per-repo grouping). */
103+
function makePR(overrides: Parameters<typeof makeFetchedPR>[0] & { repo: string }) {
104+
return makeFetchedPR({ daysSinceActivity: 5, ...overrides });
125105
}
126106

127107
/** Build a minimal DailyDigest for mock responses */

packages/core/src/commands/daily.test.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,13 @@
55
import { describe, it, expect } from 'vitest';
66
import { computeRepoSignals, computeActionMenu, groupPRsByRepo, toShelvedPRRef } from './daily.js';
77
import { deduplicateDigest, compactActionableIssues, compactRepoGroups } from '../formatters/json.js';
8-
import type { FetchedPR, DailyDigest, CommentedIssue } from '../core/types.js';
8+
import type { DailyDigest, CommentedIssue } from '../core/types.js';
99
import type { ActionableIssue, CapacityAssessment } from '../formatters/json.js';
10+
import { makeFetchedPR } from '../core/test-utils.js';
1011

11-
/** Create a minimal FetchedPR for testing signal computation */
12-
function makePR(overrides: Partial<FetchedPR> & { repo: string }): FetchedPR {
13-
const num = (overrides as { number?: number }).number ?? 1;
14-
return {
15-
id: num,
16-
url: `https://github.com/${overrides.repo}/pull/${num}`,
17-
number: num,
18-
title: 'Test PR',
19-
createdAt: '2026-01-01T00:00:00Z',
20-
updatedAt: '2026-01-15T00:00:00Z',
21-
daysSinceActivity: 5,
22-
ciStatus: 'passing',
23-
failingCheckNames: [],
24-
classifiedChecks: [],
25-
hasMergeConflict: false,
26-
reviewDecision: 'approved',
27-
hasUnrespondedComment: false,
28-
hasIncompleteChecklist: false,
29-
maintainerActionHints: [],
30-
status: 'healthy',
31-
displayLabel: '[Healthy]',
32-
displayDescription: 'Everything looks good — normal review cycle',
33-
...overrides,
34-
};
12+
/** Create a FetchedPR with repo as required (mirrors daily command's per-repo grouping). */
13+
function makePR(overrides: Parameters<typeof makeFetchedPR>[0] & { repo: string }) {
14+
return makeFetchedPR({ daysSinceActivity: 5, ...overrides });
3515
}
3616

3717
/** Create a minimal CapacityAssessment for testing */
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Tests for dashboard-formatters.ts — HTML escaping and XSS prevention.
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { escapeHtml } from './dashboard-formatters.js';
7+
8+
describe('escapeHtml', () => {
9+
it('should escape ampersand', () => {
10+
expect(escapeHtml('foo & bar')).toBe('foo &amp; bar');
11+
});
12+
13+
it('should escape less-than', () => {
14+
expect(escapeHtml('a < b')).toBe('a &lt; b');
15+
});
16+
17+
it('should escape greater-than', () => {
18+
expect(escapeHtml('a > b')).toBe('a &gt; b');
19+
});
20+
21+
it('should escape double quotes', () => {
22+
expect(escapeHtml('say "hello"')).toBe('say &quot;hello&quot;');
23+
});
24+
25+
it('should escape single quotes', () => {
26+
expect(escapeHtml("it's fine")).toBe('it&#39;s fine');
27+
});
28+
29+
it('should escape all special characters at once', () => {
30+
expect(escapeHtml('<div class="x" data-val=\'y\'>&</div>')).toBe(
31+
'&lt;div class=&quot;x&quot; data-val=&#39;y&#39;&gt;&amp;&lt;/div&gt;',
32+
);
33+
});
34+
35+
it('should return empty string unchanged', () => {
36+
expect(escapeHtml('')).toBe('');
37+
});
38+
39+
it('should return plain text unchanged', () => {
40+
expect(escapeHtml('Hello World 123')).toBe('Hello World 123');
41+
});
42+
43+
// XSS-focused regression tests
44+
it('should neutralize script injection', () => {
45+
const result = escapeHtml('<script>alert("xss")</script>');
46+
expect(result).not.toContain('<script>');
47+
expect(result).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
48+
});
49+
50+
it('should neutralize event handler injection', () => {
51+
const result = escapeHtml('<img src=x onerror="alert(1)">');
52+
expect(result).not.toContain('<img');
53+
expect(result).toBe('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
54+
});
55+
56+
it('should neutralize nested HTML tags', () => {
57+
const result = escapeHtml('<a href="javascript:void(0)">click</a>');
58+
expect(result).not.toContain('<a ');
59+
expect(result).toBe('&lt;a href=&quot;javascript:void(0)&quot;&gt;click&lt;/a&gt;');
60+
});
61+
62+
it('should handle double-escaping correctly (already escaped input)', () => {
63+
// If input already contains &amp; it should be escaped again
64+
expect(escapeHtml('&amp;')).toBe('&amp;amp;');
65+
});
66+
67+
it('should escape PR titles with special characters', () => {
68+
// Realistic PR title from GitHub
69+
expect(escapeHtml('fix: handle <T> generic in parse() & format()')).toBe(
70+
'fix: handle &lt;T&gt; generic in parse() &amp; format()',
71+
);
72+
});
73+
74+
it('should escape multi-line content', () => {
75+
const input = 'line1 <b>\nline2 "quoted"\nline3 & more';
76+
expect(escapeHtml(input)).toBe('line1 &lt;b&gt;\nline2 &quot;quoted&quot;\nline3 &amp; more');
77+
});
78+
});

packages/core/src/core/pr-monitor.test.ts

Lines changed: 27 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const { analyzeChecklist } = await import('./checklist-analysis.js');
4545
const { extractMaintainerActionHints } = await import('./maintainer-analysis.js');
4646
const { determineReviewDecision, getLatestChangesRequestedDate, checkUnrespondedComments, isAllSelfReplies } =
4747
await import('./review-analysis.js');
48+
const { makeFetchedPR } = await import('./test-utils.js');
4849

4950
describe('PRMonitor CI status deduplication', () => {
5051
const emptyCombinedStatus = {
@@ -1020,29 +1021,8 @@ describe('PRMonitor getCIStatus auth-gate filtering', () => {
10201021
});
10211022

10221023
describe('PRMonitor generateDigest', () => {
1023-
function makeFetchedPR(overrides: Partial<import('./types.js').FetchedPR> = {}): import('./types.js').FetchedPR {
1024-
return {
1025-
id: 1,
1026-
url: 'https://github.com/owner/repo/pull/1',
1027-
repo: 'owner/repo',
1028-
number: 1,
1029-
title: 'Test PR',
1030-
status: 'healthy',
1031-
displayLabel: '[Healthy]',
1032-
displayDescription: 'Everything looks good — normal review cycle',
1033-
createdAt: '2026-02-01T00:00:00Z',
1034-
updatedAt: '2026-02-07T00:00:00Z',
1035-
daysSinceActivity: 1,
1036-
ciStatus: 'passing',
1037-
failingCheckNames: [],
1038-
classifiedChecks: [],
1039-
hasMergeConflict: false,
1040-
reviewDecision: 'review_required',
1041-
hasUnrespondedComment: false,
1042-
hasIncompleteChecklist: false,
1043-
maintainerActionHints: [],
1044-
...overrides,
1045-
};
1024+
function makeDigestPR(overrides: Parameters<typeof makeFetchedPR>[0] = {}) {
1025+
return makeFetchedPR({ reviewDecision: 'review_required', ...overrides });
10461026
}
10471027

10481028
beforeEach(() => {
@@ -1064,17 +1044,17 @@ describe('PRMonitor generateDigest', () => {
10641044

10651045
it('should categorize PRs by status', () => {
10661046
const prs = [
1067-
makeFetchedPR({ status: 'needs_response', number: 1 }),
1068-
makeFetchedPR({ status: 'failing_ci', number: 2 }),
1069-
makeFetchedPR({ status: 'merge_conflict', number: 3 }),
1070-
makeFetchedPR({ status: 'healthy', number: 4 }),
1071-
makeFetchedPR({ status: 'dormant', number: 5 }),
1072-
makeFetchedPR({ status: 'approaching_dormant', number: 6 }),
1073-
makeFetchedPR({ status: 'needs_changes', number: 7 }),
1074-
makeFetchedPR({ status: 'changes_addressed', number: 8 }),
1075-
makeFetchedPR({ status: 'waiting_on_maintainer', number: 9 }),
1076-
makeFetchedPR({ status: 'incomplete_checklist', number: 10 }),
1077-
makeFetchedPR({ status: 'waiting', number: 11 }),
1047+
makeDigestPR({ status: 'needs_response', number: 1 }),
1048+
makeDigestPR({ status: 'failing_ci', number: 2 }),
1049+
makeDigestPR({ status: 'merge_conflict', number: 3 }),
1050+
makeDigestPR({ status: 'healthy', number: 4 }),
1051+
makeDigestPR({ status: 'dormant', number: 5 }),
1052+
makeDigestPR({ status: 'approaching_dormant', number: 6 }),
1053+
makeDigestPR({ status: 'needs_changes', number: 7 }),
1054+
makeDigestPR({ status: 'changes_addressed', number: 8 }),
1055+
makeDigestPR({ status: 'waiting_on_maintainer', number: 9 }),
1056+
makeDigestPR({ status: 'incomplete_checklist', number: 10 }),
1057+
makeDigestPR({ status: 'waiting', number: 11 }),
10781058
];
10791059

10801060
const monitor = new PRMonitor('fake-token');
@@ -1094,18 +1074,18 @@ describe('PRMonitor generateDigest', () => {
10941074

10951075
it('should calculate totalNeedingAttention correctly', () => {
10961076
const prs = [
1097-
makeFetchedPR({ status: 'needs_response', number: 1 }),
1098-
makeFetchedPR({ status: 'needs_changes', number: 2 }),
1099-
makeFetchedPR({ status: 'failing_ci', number: 3 }),
1100-
makeFetchedPR({ status: 'merge_conflict', number: 4 }),
1101-
makeFetchedPR({ status: 'needs_rebase', number: 5 }),
1102-
makeFetchedPR({ status: 'missing_required_files', number: 6 }),
1103-
makeFetchedPR({ status: 'incomplete_checklist', number: 7 }),
1077+
makeDigestPR({ status: 'needs_response', number: 1 }),
1078+
makeDigestPR({ status: 'needs_changes', number: 2 }),
1079+
makeDigestPR({ status: 'failing_ci', number: 3 }),
1080+
makeDigestPR({ status: 'merge_conflict', number: 4 }),
1081+
makeDigestPR({ status: 'needs_rebase', number: 5 }),
1082+
makeDigestPR({ status: 'missing_required_files', number: 6 }),
1083+
makeDigestPR({ status: 'incomplete_checklist', number: 7 }),
11041084
// These should NOT count toward totalNeedingAttention
1105-
makeFetchedPR({ status: 'healthy', number: 8 }),
1106-
makeFetchedPR({ status: 'waiting', number: 9 }),
1107-
makeFetchedPR({ status: 'dormant', number: 10 }),
1108-
makeFetchedPR({ status: 'waiting_on_maintainer', number: 11 }),
1085+
makeDigestPR({ status: 'healthy', number: 8 }),
1086+
makeDigestPR({ status: 'waiting', number: 9 }),
1087+
makeDigestPR({ status: 'dormant', number: 10 }),
1088+
makeDigestPR({ status: 'waiting_on_maintainer', number: 11 }),
11091089
];
11101090

11111091
const monitor = new PRMonitor('fake-token');
@@ -1764,29 +1744,8 @@ describe('isAcknowledgmentComment (Issue #69)', () => {
17641744
});
17651745

17661746
describe('computeDisplayLabel (#79)', () => {
1767-
function makePR(overrides: Partial<import('./types.js').FetchedPR> = {}): import('./types.js').FetchedPR {
1768-
return {
1769-
id: 1,
1770-
url: 'https://github.com/owner/repo/pull/1',
1771-
repo: 'owner/repo',
1772-
number: 1,
1773-
title: 'Test PR',
1774-
status: 'healthy',
1775-
displayLabel: '',
1776-
displayDescription: '',
1777-
createdAt: '2026-02-01T00:00:00Z',
1778-
updatedAt: '2026-02-07T00:00:00Z',
1779-
daysSinceActivity: 1,
1780-
ciStatus: 'passing',
1781-
failingCheckNames: [],
1782-
classifiedChecks: [],
1783-
hasMergeConflict: false,
1784-
reviewDecision: 'review_required',
1785-
hasUnrespondedComment: false,
1786-
hasIncompleteChecklist: false,
1787-
maintainerActionHints: [],
1788-
...overrides,
1789-
};
1747+
function makePR(overrides: Parameters<typeof makeFetchedPR>[0] = {}) {
1748+
return makeFetchedPR({ displayLabel: '', displayDescription: '', reviewDecision: 'review_required', ...overrides });
17901749
}
17911750

17921751
it('should return [Healthy] for healthy status', () => {

0 commit comments

Comments
 (0)