mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
The Usage tab shows a bar for each configured limit. It turns amber at 80% and red at 100%. The tab also shows rejected requests per day, a projected month-end cost with budget headroom, and CSV export for users and groups. Names link to Charts with the matching scope. The Charts tab ranks models over the full 24h, 30d or 12mo window. A provider filter is applied on the server, and the ranking can be exported as CSV. A new Events tab lists recent failures, filtered by kind and user. The selected tab, filters and scope live in the URL hash. Polling stops while the page is hidden. Views show which replica answered, and a banner lists configuration warnings. CSV cells that start with a formula character are neutralised. Links sit outside the accordion buttons, so both controls keep their accessible names. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
87 lines
3.3 KiB
TypeScript
87 lines
3.3 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { toCsv } from './csv'
|
|
|
|
describe('toCsv', () => {
|
|
it('joins a header row and data rows with CRLF, including a trailing CRLF', () => {
|
|
expect(toCsv(['a', 'b'], [['1', '2']])).toBe('a,b\r\n1,2\r\n')
|
|
})
|
|
|
|
it('renders multiple data rows in order', () => {
|
|
expect(toCsv(['id'], [['x'], ['y'], ['z']])).toBe('id\r\nx\r\ny\r\nz\r\n')
|
|
})
|
|
|
|
it('leaves a plain field bare (no quotes) when it has no special characters', () => {
|
|
expect(toCsv(['name'], [['alice']])).toBe('name\r\nalice\r\n')
|
|
})
|
|
|
|
it('quotes a field containing a comma', () => {
|
|
expect(toCsv(['name'], [['alice, bob']])).toBe('name\r\n"alice, bob"\r\n')
|
|
})
|
|
|
|
it('quotes a field containing a double quote and doubles the embedded quote', () => {
|
|
expect(toCsv(['name'], [['say "hi"']])).toBe('name\r\n"say ""hi"""\r\n')
|
|
})
|
|
|
|
it('quotes a field containing an embedded newline', () => {
|
|
expect(toCsv(['note'], [['line1\nline2']])).toBe('note\r\n"line1\nline2"\r\n')
|
|
})
|
|
|
|
it('quotes a field containing an embedded carriage return', () => {
|
|
expect(toCsv(['note'], [['line1\rline2']])).toBe('note\r\n"line1\rline2"\r\n')
|
|
})
|
|
|
|
it('stringifies numbers and booleans without caller-side conversion', () => {
|
|
expect(toCsv(['n', 'b'], [[42, true]])).toBe('n,b\r\n42,true\r\n')
|
|
})
|
|
|
|
it('renders null/undefined fields as empty, never the literal text "null"/"undefined"', () => {
|
|
expect(toCsv(['a', 'b'], [[null, undefined]])).toBe('a,b\r\n,\r\n')
|
|
})
|
|
|
|
it('quotes a header cell too, not just data cells', () => {
|
|
expect(toCsv(['a, b'], [['x']])).toBe('"a, b"\r\nx\r\n')
|
|
})
|
|
|
|
it('renders just the header row (CRLF-terminated) when there are no data rows', () => {
|
|
expect(toCsv(['only'], [])).toBe('only\r\n')
|
|
})
|
|
})
|
|
|
|
// P4 (security): OWASP CSV Injection — a cell whose text begins with a
|
|
// formula-trigger character must be neutralized with a leading single
|
|
// quote before any other quoting, so a malicious/upstream-controlled id
|
|
// never executes as a formula when the export is opened in a spreadsheet.
|
|
describe('toCsv: formula injection (P4)', () => {
|
|
it.each([
|
|
['=cmd', `id\r\n'=cmd\r\n`],
|
|
['+1+1', `id\r\n'+1+1\r\n`],
|
|
['-1+1', `id\r\n'-1+1\r\n`],
|
|
['@SUM(A1:A9)', `id\r\n'@SUM(A1:A9)\r\n`],
|
|
['\tcmd', `id\r\n'\tcmd\r\n`],
|
|
// A leading \r is ALSO an RFC 4180 special character, so this one gets
|
|
// both defenses: prefixed, then quoted (no embedded quote to double).
|
|
['\rcmd', `id\r\n"'\rcmd"\r\n`],
|
|
])('prefixes a cell starting with a formula-trigger character: %j', (raw, expected) => {
|
|
expect(toCsv(['id'], [[raw]])).toBe(expected)
|
|
})
|
|
|
|
it('leaves an ordinary id (no leading formula-trigger character) unprefixed', () => {
|
|
const csv = toCsv(['id'], [['openai/gpt-4']])
|
|
expect(csv).toBe('id\r\nopenai/gpt-4\r\n')
|
|
})
|
|
|
|
it('a formula-triggering value that ALSO needs comma/quote-quoting gets both defenses (prefixed, then quoted)', () => {
|
|
const csv = toCsv(['id'], [['=A1, "x"']])
|
|
expect(csv).toBe(`id\r\n"'=A1, ""x"""\r\n`)
|
|
})
|
|
|
|
it('does not treat a bare "=" alone as anything other than a formula trigger (still prefixed)', () => {
|
|
expect(toCsv(['v'], [['=']])).toBe("v\r\n'=\r\n")
|
|
})
|
|
|
|
it('a value merely CONTAINING (not starting with) a trigger character is left unprefixed', () => {
|
|
expect(toCsv(['note'], [['cost=5']])).toBe('note\r\ncost=5\r\n')
|
|
})
|
|
})
|