SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ACCESS_TOKEN_MIN_LENGTH,
|
||||
ACCESS_TOKEN_MIN_SCORE,
|
||||
assessAccessToken,
|
||||
} from '../src/access-token';
|
||||
|
||||
describe('access token strength', () => {
|
||||
it('requires the configured minimum length', () => {
|
||||
expect(assessAccessToken('a'.repeat(ACCESS_TOKEN_MIN_LENGTH - 1))).toMatchObject({
|
||||
acceptable: false,
|
||||
reason: 'too-short',
|
||||
});
|
||||
});
|
||||
|
||||
it('scores system and user-specific words as guessable', () => {
|
||||
expect(
|
||||
assessAccessToken('SnowLumaSnowLuma', ['SnowLuma', 'OneBot', '10001']),
|
||||
).toMatchObject({
|
||||
acceptable: false,
|
||||
reason: 'guessable',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a sufficiently long, hard-to-guess token', () => {
|
||||
const result = assessAccessToken('E5xqVb7_9pLt2QwR4M8kZ1nY', ['SnowLuma', 'OneBot']);
|
||||
|
||||
expect(result.acceptable).toBe(true);
|
||||
expect(result.reason).toBe('acceptable');
|
||||
expect(result.score).toBeGreaterThanOrEqual(ACCESS_TOKEN_MIN_SCORE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isObject, boolOr, clampNum, clampInt } from '../src/coerce';
|
||||
|
||||
describe('isObject', () => {
|
||||
it('is true only for plain non-null non-array objects', () => {
|
||||
expect(isObject({})).toBe(true);
|
||||
expect(isObject({ a: 1 })).toBe(true);
|
||||
expect(isObject(null)).toBe(false);
|
||||
expect(isObject([])).toBe(false);
|
||||
expect(isObject('x')).toBe(false);
|
||||
expect(isObject(42)).toBe(false);
|
||||
expect(isObject(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolOr', () => {
|
||||
it('returns the value only when it is a real boolean, else the fallback', () => {
|
||||
expect(boolOr(true, false)).toBe(true);
|
||||
expect(boolOr(false, true)).toBe(false);
|
||||
expect(boolOr('true', false)).toBe(false); // strings are NOT coerced
|
||||
expect(boolOr(1, false)).toBe(false);
|
||||
expect(boolOr(undefined, true)).toBe(true);
|
||||
expect(boolOr(null, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampNum', () => {
|
||||
it('passes finite numbers through, clamped to [min,max]', () => {
|
||||
expect(clampNum(5, 0, 10, -1)).toBe(5);
|
||||
expect(clampNum(-3, 0, 10, -1)).toBe(0); // below min
|
||||
expect(clampNum(99, 0, 10, -1)).toBe(10); // above max
|
||||
});
|
||||
|
||||
it('coerces numeric strings', () => {
|
||||
expect(clampNum('7', 0, 10, -1)).toBe(7);
|
||||
expect(clampNum('7.5', 0, 10, -1)).toBe(7.5); // NOT truncated (that is clampInt)
|
||||
});
|
||||
|
||||
it('falls back on non-finite / non-numeric input', () => {
|
||||
expect(clampNum('abc', 0, 10, 3)).toBe(3);
|
||||
expect(clampNum(NaN, 0, 10, 3)).toBe(3);
|
||||
expect(clampNum(Infinity, 0, 10, 3)).toBe(3);
|
||||
expect(clampNum(undefined, 0, 10, 3)).toBe(3);
|
||||
expect(clampNum(null, 0, 10, 3)).toBe(3);
|
||||
expect(clampNum({}, 0, 10, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it('preserves fractional precision within bounds', () => {
|
||||
expect(clampNum(0.42, 0, 1, 0)).toBe(0.42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampInt', () => {
|
||||
it('is clampNum truncated toward zero', () => {
|
||||
expect(clampInt(7.9, 0, 10, -1)).toBe(7);
|
||||
expect(clampInt('7.9', 0, 10, -1)).toBe(7);
|
||||
expect(clampInt(5, 0, 10, -1)).toBe(5);
|
||||
});
|
||||
|
||||
it('clamps before truncating', () => {
|
||||
expect(clampInt(99.9, 0, 10, -1)).toBe(10);
|
||||
expect(clampInt(-3.5, 0, 10, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamp-then-trunc ordering: a fractional lower bound is crossed by the clamp, not the trunc', () => {
|
||||
// Distinguishes clamp→trunc (this contract) from trunc→clamp: input 2.4
|
||||
// with min 2.5 → clamp lifts to 2.5, then trunc → 2. A trunc-first impl
|
||||
// would floor to 2 then clamp up to 2.5. Pins the order for the shared
|
||||
// primitive (future callers may use fractional bounds).
|
||||
expect(clampInt(2.4, 2.5, 10, 0)).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back on non-finite input', () => {
|
||||
expect(clampInt('abc', 0, 10, 4)).toBe(4);
|
||||
expect(clampInt(NaN, 0, 10, 4)).toBe(4);
|
||||
expect(clampInt(undefined, 0, 10, 4)).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mapWithConcurrency } from '../src/concurrency';
|
||||
|
||||
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
describe('mapWithConcurrency', () => {
|
||||
it('returns results in input order', async () => {
|
||||
const out = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (n) => n * 10);
|
||||
expect(out).toEqual([10, 20, 30, 40, 50]);
|
||||
});
|
||||
|
||||
it('passes the absolute index (across batch boundaries)', async () => {
|
||||
const seen: Array<[number, number]> = [];
|
||||
await mapWithConcurrency(['a', 'b', 'c', 'd', 'e'], 2, async (item, i) => {
|
||||
seen.push([i, item.charCodeAt(0)]);
|
||||
return i;
|
||||
});
|
||||
expect(seen.map(([i]) => i).sort((x, y) => x - y)).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('never runs more than `concurrency` at once (batched), and serializes across batches', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const order: number[] = [];
|
||||
await mapWithConcurrency([0, 1, 2, 3, 4, 5, 6], 3, async (n) => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await tick();
|
||||
order.push(n);
|
||||
inFlight--;
|
||||
return n;
|
||||
});
|
||||
expect(peak).toBeLessThanOrEqual(3);
|
||||
// batched: first 3 (0,1,2) settle before the next batch (3,4,5) starts.
|
||||
expect(order.slice(0, 3).sort((a, b) => a - b)).toEqual([0, 1, 2]);
|
||||
expect(order.slice(3, 6).sort((a, b) => a - b)).toEqual([3, 4, 5]);
|
||||
expect(order[6]).toBe(6);
|
||||
});
|
||||
|
||||
it('handles a length not divisible by concurrency (short final batch)', async () => {
|
||||
const out = await mapWithConcurrency([1, 2, 3, 4, 5], 3, async (n) => n);
|
||||
expect(out).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('runs everything in one batch when concurrency >= length', async () => {
|
||||
let peak = 0,
|
||||
inFlight = 0;
|
||||
const out = await mapWithConcurrency([1, 2, 3], 10, async (n) => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await tick();
|
||||
inFlight--;
|
||||
return n;
|
||||
});
|
||||
expect(out).toEqual([1, 2, 3]);
|
||||
expect(peak).toBe(3);
|
||||
});
|
||||
|
||||
it('returns [] for an empty input without calling fn', async () => {
|
||||
let called = 0;
|
||||
const out = await mapWithConcurrency([], 4, async (n) => {
|
||||
called++;
|
||||
return n;
|
||||
});
|
||||
expect(out).toEqual([]);
|
||||
expect(called).toBe(0);
|
||||
});
|
||||
|
||||
it('treats concurrency < 1 as 1 (fully serial)', async () => {
|
||||
let peak = 0,
|
||||
inFlight = 0;
|
||||
await mapWithConcurrency([1, 2, 3], 0, async (n) => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await tick();
|
||||
inFlight--;
|
||||
return n;
|
||||
});
|
||||
expect(peak).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { positiveIntEnv } from '../src/env';
|
||||
|
||||
describe('positiveIntEnv', () => {
|
||||
it('returns the fallback when the variable is absent or blank', () => {
|
||||
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: {} })).toBe(15);
|
||||
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: { SNOWLUMA_TEST_INT: ' ' } })).toBe(
|
||||
15,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads a positive integer and honors the max', () => {
|
||||
expect(
|
||||
positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: { SNOWLUMA_TEST_INT: '2500' } }),
|
||||
).toBe(2500);
|
||||
expect(
|
||||
positiveIntEnv('SNOWLUMA_TEST_INT', 15, {
|
||||
max: 65535,
|
||||
env: { SNOWLUMA_TEST_INT: '80' },
|
||||
}),
|
||||
).toBe(80);
|
||||
});
|
||||
|
||||
it('ignores zero, negatives, fractions, and values above the max', () => {
|
||||
for (const raw of ['0', '-5', '1.5', 'nope', '70000']) {
|
||||
expect(
|
||||
positiveIntEnv('SNOWLUMA_TEST_INT', 15, {
|
||||
max: 65535,
|
||||
env: { SNOWLUMA_TEST_INT: raw },
|
||||
}),
|
||||
).toBe(15);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fromHex, hexPreview, toHex, toHexUpper } from '../src/hex';
|
||||
|
||||
describe('hex', () => {
|
||||
it('encodes only the selected byte view', () => {
|
||||
const bytes = Uint8Array.of(0xff, 0x01, 0xab, 0xff);
|
||||
expect(toHex(bytes.subarray(1, 3))).toBe('01ab');
|
||||
expect(toHexUpper(Buffer.from(bytes).subarray(1, 3))).toBe('01AB');
|
||||
});
|
||||
|
||||
it('toHex / toHexUpper render bytes as zero-padded hex', () => {
|
||||
const b = Uint8Array.from([0x00, 0x0f, 0xa0, 0xff]);
|
||||
expect(toHex(b)).toBe('000fa0ff');
|
||||
expect(toHexUpper(b)).toBe('000FA0FF');
|
||||
expect(toHex(Uint8Array.of())).toBe('');
|
||||
});
|
||||
|
||||
it('hexPreview appends "..." only past maxBytes', () => {
|
||||
const b = Uint8Array.from([1, 2, 3, 4, 5]);
|
||||
expect(hexPreview(b)).toBe('0102030405'); // under the default 64-byte cap
|
||||
expect(hexPreview(b, 5)).toBe('0102030405'); // exactly at the cap → no ellipsis
|
||||
expect(hexPreview(b, 2)).toBe('0102...'); // truncated
|
||||
expect(hexPreview(b, 0)).toBe('...'); // nothing shown, but bytes remain
|
||||
});
|
||||
|
||||
it('fromHex round-trips toHex and rejects odd-length input', () => {
|
||||
const b = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
|
||||
expect(fromHex(toHex(b)).equals(b)).toBe(true);
|
||||
expect(toHex(fromHex('00ffa0'))).toBe('00ffa0');
|
||||
expect(() => fromHex('abc')).toThrow();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { sanitizeLogLine } from '../src/log-sanitize';
|
||||
|
||||
describe('sanitizeLogLine', () => {
|
||||
it('removes SGR styling in seven-bit and eight-bit forms', () => {
|
||||
expect(sanitizeLogLine('\x1b[31mred\x1b[0m / \u009b32mgreen\u009b0m')).toBe('red / green');
|
||||
});
|
||||
|
||||
it('removes OSC hyperlinks without removing their visible label', () => {
|
||||
expect(sanitizeLogLine('\x1b]8;;https://example.com\x07visible\x1b]8;;\x07')).toBe(
|
||||
'visible',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes terminal control-string payloads', () => {
|
||||
expect(sanitizeLogLine('before\x1bPprivate payload\x1b\\after')).toBe('beforeafter');
|
||||
});
|
||||
|
||||
it('preserves tabs and newlines while removing other controls', () => {
|
||||
expect(sanitizeLogLine('one\ttwo\nthree\r\x00')).toBe('one\ttwo\nthree');
|
||||
});
|
||||
|
||||
it('does not leak unterminated terminal strings', () => {
|
||||
expect(sanitizeLogLine('visible\x1b]8;;https://example.com')).toBe('visible');
|
||||
});
|
||||
|
||||
it('preserves long Unicode records and adjacent visible spans', () => {
|
||||
const body = '消息 🌌 e\u0301\tpacket=0011aabb\n'.repeat(1000);
|
||||
expect(sanitizeLogLine(body)).toBe(body);
|
||||
expect(sanitizeLogLine(`\x1b[32m${body}\x1b[0m\x00tail\x1b`)).toBe(`${body}tail`);
|
||||
});
|
||||
|
||||
it.each(['\x1bP', '\x1bX', '\x1b^', '\x1b_', '\u0090', '\u0098', '\u009e', '\u009f'])(
|
||||
'removes %j control strings between visible spans',
|
||||
(opener) => {
|
||||
expect(sanitizeLogLine(`before${opener}private\u009cafter`)).toBe('beforeafter');
|
||||
expect(sanitizeLogLine(`before${opener}private\x1b\\after`)).toBe('beforeafter');
|
||||
expect(sanitizeLogLine(`before${opener}private`)).toBe('before');
|
||||
},
|
||||
);
|
||||
|
||||
it('handles consecutive controls and records consisting only of controls', () => {
|
||||
expect(sanitizeLogLine('\x00\x1b[31m\u009b0m\r\u007f')).toBe('');
|
||||
expect(sanitizeLogLine('a\x00\x1b[31m\u009b0m\r\u007fb')).toBe('ab');
|
||||
expect(sanitizeLogLine('')).toBe('');
|
||||
});
|
||||
|
||||
it('preserves every visible UTF-16 code unit when removing a large hidden payload', () => {
|
||||
const visible = 'visible 🌌 e\u0301\ud800x\udc00';
|
||||
expect(sanitizeLogLine(`${visible}\x1b]${'hidden'.repeat(1000)}`)).toBe(visible);
|
||||
});
|
||||
|
||||
it('does not retain discarded control-string payloads through a short visible span', () => {
|
||||
const moduleUrl = new URL('../src/log-sanitize.ts', import.meta.url).href;
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--expose-gc',
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import { sanitizeLogLine } from ${JSON.stringify(moduleUrl)};
|
||||
async function used() {
|
||||
await setImmediate();
|
||||
global.gc();
|
||||
global.gc();
|
||||
return process.memoryUsage().heapUsed;
|
||||
}
|
||||
function record(i) {
|
||||
return sanitizeLogLine('visible record ' + i + '\\x1b]' + 'x'.repeat(1024 * 1024));
|
||||
}
|
||||
const before = await used();
|
||||
const records = Array.from({ length: 16 }, (_, i) => record(i));
|
||||
const retained = (await used()) - before;
|
||||
console.log(JSON.stringify({ retained, records }));
|
||||
`,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 10_000 },
|
||||
);
|
||||
const result = JSON.parse(output);
|
||||
expect(result.records).toEqual(Array.from({ length: 16 }, (_, i) => `visible record ${i}`));
|
||||
// Leave ample GC/runtime overhead while rejecting retention of the 16 MiB input.
|
||||
expect(result.retained).toBeLessThan(4 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { redactLogMessage, renderParamsVerbose, summarizeParams } from '../src/log-summary';
|
||||
|
||||
describe('summarizeParams', () => {
|
||||
it('renders null/undefined as {}', () => {
|
||||
expect(summarizeParams(null)).toBe('{}');
|
||||
expect(summarizeParams(undefined)).toBe('{}');
|
||||
});
|
||||
|
||||
it('renders a top-level non-object via String() WITHOUT quotes', () => {
|
||||
// The reported change-request: a top-level primitive is NOT JSON-quoted.
|
||||
expect(summarizeParams('hello')).toBe('hello');
|
||||
expect(summarizeParams(123)).toBe('123');
|
||||
});
|
||||
|
||||
it('collapses a top-level array to [len=N]', () => {
|
||||
expect(summarizeParams([1, 2, 3])).toBe('[len=3]');
|
||||
});
|
||||
|
||||
it('quotes string fields, collapses nested values, truncates long strings', () => {
|
||||
expect(summarizeParams({ a: 1, b: 'x', c: { d: 1 }, e: [1, 2] })).toBe(
|
||||
'a=1 b="x" c={...} e=[len=2]',
|
||||
);
|
||||
expect(summarizeParams({ s: 'x'.repeat(50) })).toBe(`s="${'x'.repeat(40)}..."`);
|
||||
});
|
||||
|
||||
it('redacts authentication fields without exposing their values', () => {
|
||||
const out = summarizeParams({
|
||||
token: 'token-value',
|
||||
Cookie: 'cookie-value',
|
||||
Authorization: 'Bearer authorization-value',
|
||||
user_password: 'password-value',
|
||||
apiKey: 'key-value',
|
||||
group_id: 12345,
|
||||
});
|
||||
|
||||
expect(out).toContain('token="***"');
|
||||
expect(out).toContain('Cookie="***"');
|
||||
expect(out).toContain('Authorization="***"');
|
||||
expect(out).toContain('user_password="***"');
|
||||
expect(out).toContain('apiKey="***"');
|
||||
expect(out).toContain('group_id=12345');
|
||||
expect(out).not.toMatch(
|
||||
/token-value|cookie-value|authorization-value|password-value|key-value/,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat sensitive substrings inside ordinary keys as credentials', () => {
|
||||
expect(
|
||||
summarizeParams({
|
||||
retoken: 'visible',
|
||||
cookietown: 'visible',
|
||||
myauthorization: 'visible',
|
||||
}),
|
||||
).toBe('retoken="visible" cookietown="visible" myauthorization="visible"');
|
||||
});
|
||||
|
||||
it('caps total output and appends "..." on overflow', () => {
|
||||
const big: Record<string, number> = {};
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
big['key' + i] = i;
|
||||
}
|
||||
|
||||
const out = summarizeParams(big);
|
||||
expect(out.startsWith('key0=0')).toBe(true);
|
||||
expect(out.endsWith('...')).toBe(true);
|
||||
expect(out.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('caps a single oversized first key and top-level primitive', () => {
|
||||
const oversizedKey = 'k'.repeat(300);
|
||||
|
||||
const objectOut = summarizeParams({ [oversizedKey]: 'value' });
|
||||
const primitiveOut = summarizeParams('x'.repeat(300));
|
||||
|
||||
expect(objectOut).toHaveLength(200);
|
||||
expect(objectOut.endsWith('...')).toBe(true);
|
||||
expect(primitiveOut).toHaveLength(200);
|
||||
expect(primitiveOut.endsWith('...')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactLogMessage', () => {
|
||||
it('redacts explicit authentication assignments without changing prose', () => {
|
||||
const out = redactLogMessage(
|
||||
'password=secret Authorization: Bearer bearer-secret cookie="cookie secret" password change required',
|
||||
);
|
||||
|
||||
expect(out).toBe('password=*** Authorization: *** cookie=*** password change required');
|
||||
});
|
||||
|
||||
it('redacts quoted object keys, Basic auth, and complete Cookie values', () => {
|
||||
const out = redactLogMessage(
|
||||
`json={"authorization":"Basic json-auth"} object={ 'api-key': 'api-secret' } Authorization: Basic header-auth Cookie=session=cookie-secret; Path=/; HttpOnly`,
|
||||
);
|
||||
const expires = redactLogMessage(
|
||||
'Cookie=session=expires-secret; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly safe=visible',
|
||||
);
|
||||
|
||||
expect(out).toContain('json={"authorization":***}');
|
||||
expect(out).toContain("object={ 'api-key': *** }");
|
||||
expect(out).toContain('Authorization: *** Cookie=***');
|
||||
expect(expires).toBe('Cookie=*** safe=visible');
|
||||
expect(out).not.toMatch(/json-auth|api-secret|header-auth|cookie-secret|Path=|HttpOnly/);
|
||||
expect(expires).not.toMatch(/expires-secret|Expires|21 Oct|Path=|HttpOnly/);
|
||||
});
|
||||
|
||||
it('redacts complete multi-parameter Authorization values', () => {
|
||||
const out = redactLogMessage(
|
||||
'Authorization: Digest username="alice", realm="qq", response="digest-secret"\n' +
|
||||
'authorization=AWS4-HMAC-SHA256 Credential=AKID/20260730/cn/service/aws4_request, ' +
|
||||
'SignedHeaders=host;x-date, Signature=aws-secret\n' +
|
||||
'Authorization: Bearer bearer-secret Cookie=session=cookie-secret; Path=/',
|
||||
);
|
||||
|
||||
expect(out).toBe('Authorization: ***\nauthorization=***\nAuthorization: *** Cookie=***');
|
||||
expect(out).not.toMatch(
|
||||
/alice|realm|digest-secret|AKID|SignedHeaders|aws-secret|bearer-secret|cookie-secret|Path=/,
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts camelCase authentication keys without matching larger words', () => {
|
||||
const out = redactLogMessage(
|
||||
`authToken=auth-secret idToken=id-secret clientSecret=client-secret {"userPassword":"password-secret"}`,
|
||||
);
|
||||
|
||||
expect(out).toBe(`authToken=*** idToken=*** clientSecret=*** {"userPassword":***}`);
|
||||
expect(out).not.toMatch(/auth-secret|id-secret|client-secret|password-secret/);
|
||||
expect(redactLogMessage('retoken=value cookietown=value myauthorization=value')).toBe(
|
||||
'retoken=value cookietown=value myauthorization=value',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts leading separator and numeric authentication keys without matching larger words', () => {
|
||||
expect(
|
||||
redactLogMessage(
|
||||
'_token=underscore-secret --password=cli-secret 2faToken=two-factor-secret retoken=visible',
|
||||
),
|
||||
).toBe('_token=*** --password=*** 2faToken=*** retoken=visible');
|
||||
});
|
||||
|
||||
it('redacts nested assignments inside non-sensitive wrapper values', () => {
|
||||
expect(redactLogMessage('params=authToken=auth-secret params=cookie="cookie-secret"')).toBe(
|
||||
'params=authToken=*** params=cookie=***',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts the entire Authorization value when it starts with a marker', () => {
|
||||
expect(redactLogMessage('Authorization: *** secret-value')).toBe('Authorization: ***');
|
||||
});
|
||||
|
||||
it('redacts property paths and URL query authentication values', () => {
|
||||
const out = redactLogMessage(
|
||||
'headers.authorization=Basic dXNlcjpwYXNz ' +
|
||||
'request.token=plain-token ' +
|
||||
'GET /callback?access_token=query-secret&x=1 ' +
|
||||
'url=https://host/?api_key=url-secret',
|
||||
);
|
||||
|
||||
expect(out).toContain('headers.authorization=***');
|
||||
expect(out).toContain('request.token=***');
|
||||
expect(out).toContain('/callback?access_token=***&x=1');
|
||||
expect(out).toContain('https://host/?api_key=***');
|
||||
expect(out).not.toMatch(/dXNlcjpwYXNz|plain-token|query-secret|url-secret/);
|
||||
});
|
||||
|
||||
it('redacts prefixed authentication keys without matching larger words', () => {
|
||||
const redacted = redactLogMessage(
|
||||
'x-api-key=api-secret user_password=password-secret ' +
|
||||
'headers.authorization=Basic auth-secret ' +
|
||||
'x-cookie="session=cookie-secret"; Path=/; HttpOnly',
|
||||
);
|
||||
|
||||
expect(redacted).toContain('x-api-key=***');
|
||||
expect(redacted).toContain('user_password=***');
|
||||
expect(redacted).toContain('headers.authorization=***');
|
||||
expect(redacted).toContain('x-cookie=***');
|
||||
expect(redacted).not.toMatch(
|
||||
/api-secret|password-secret|auth-secret|cookie-secret|Path=|HttpOnly/,
|
||||
);
|
||||
expect(redactLogMessage('retoken=value cookietown=value myauthorization=value')).toBe(
|
||||
'retoken=value cookietown=value myauthorization=value',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderParamsVerbose', () => {
|
||||
it('keeps authentication fields unredacted for complete TRACE diagnostics', () => {
|
||||
const out = renderParamsVerbose({
|
||||
token: 'token-value',
|
||||
Cookie: 'cookie-value',
|
||||
password: 'password-value',
|
||||
apiKey: 'key-value',
|
||||
});
|
||||
|
||||
expect(out).toContain('token:"token-value"');
|
||||
expect(out).toContain('Cookie:"cookie-value"');
|
||||
expect(out).toContain('password:"password-value"');
|
||||
expect(out).toContain('apiKey:"key-value"');
|
||||
expect(out).not.toContain('"***"');
|
||||
});
|
||||
|
||||
it('does not truncate TRACE values or their enclosing object', () => {
|
||||
const unicodeValue = '雪'.repeat(800);
|
||||
const out = renderParamsVerbose({ blob: unicodeValue, tail: 'complete' });
|
||||
|
||||
expect(out).toContain(`blob:${JSON.stringify(unicodeValue)}`);
|
||||
expect(out).toContain('tail:"complete"');
|
||||
expect(out).not.toMatch(/…<\d+B>|\.\.\./);
|
||||
});
|
||||
|
||||
it('guards against circular references', () => {
|
||||
const o: Record<string, unknown> = { a: 1 };
|
||||
o.self = o;
|
||||
expect(renderParamsVerbose(o)).toContain('"[circular]"');
|
||||
});
|
||||
|
||||
it('renders shared non-cyclic references in every branch', () => {
|
||||
const shared = { value: 1 };
|
||||
|
||||
expect(renderParamsVerbose({ left: shared, right: shared })).toBe(
|
||||
'{left:{value:1},right:{value:1}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders nested structure with primitives intact', () => {
|
||||
expect(renderParamsVerbose({ a: 1, b: [true, 'x'] })).toBe('{a:1,b:[true,"x"]}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { fileWrite } = vi.hoisted(() => ({ fileWrite: vi.fn() }));
|
||||
|
||||
vi.mock('../src/log-file-transport', () => ({
|
||||
getFileTransport: () => ({ write: fileWrite, close: async () => {} }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
fileWrite.mockClear();
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
vi.stubEnv('SNOWLUMA_LOG_LEVEL', 'trace');
|
||||
vi.stubEnv('SNOWLUMA_LOG_FILE_LEVEL', 'error');
|
||||
vi.stubEnv('SNOWLUMA_TRACE_BUFFER', '100');
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
const logger = await import('../src/logger');
|
||||
return { ...logger, log: logger.createLogger('Snapshot') };
|
||||
}
|
||||
|
||||
describe('retained log snapshots', () => {
|
||||
it('returns empty snapshots before any record has been emitted', async () => {
|
||||
const logger = await setup();
|
||||
expect(logger.getRecentLogs()).toEqual([]);
|
||||
expect(logger.getLogSnapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
it('merges wrapped rings in emission order and returns only the requested tail', async () => {
|
||||
const logger = await setup();
|
||||
const emitted: import('../src/logger').LogEntry[] = [];
|
||||
const unsubscribe = logger.subscribeLogs((entry) => emitted.push(entry));
|
||||
|
||||
for (let i = 0; i < 1100; i += 1) {
|
||||
logger.log.info('normal %d', i);
|
||||
logger.log.trace('trace %d', i);
|
||||
}
|
||||
|
||||
unsubscribe();
|
||||
const normal = emitted.filter((entry) => entry.level !== 'trace').slice(-1000);
|
||||
const trace = emitted.filter((entry) => entry.level === 'trace').slice(-100);
|
||||
const expected = [...normal, ...trace].sort((a, b) => a.id - b.id);
|
||||
expect(logger.getLogSnapshot()).toEqual(expected);
|
||||
|
||||
for (const limit of [1, 2, 30, 300, 1000, 1100, 2000, 0, -1, 2.9, NaN, Infinity]) {
|
||||
expect(logger.getRecentLogs(limit)).toEqual(
|
||||
expected.slice(-Math.max(1, Math.trunc(limit))),
|
||||
);
|
||||
}
|
||||
|
||||
expect(logger.getRecentLogs()).toEqual(expected.slice(-300));
|
||||
const snapshot = logger.getRecentLogs(5);
|
||||
snapshot.pop();
|
||||
expect(logger.getRecentLogs(5)).toEqual(expected.slice(-5));
|
||||
});
|
||||
|
||||
it.each(['info', 'trace'] as const)(
|
||||
'reads a tail when only the %s ring has records',
|
||||
async (level) => {
|
||||
const logger = await setup();
|
||||
logger.log[level]('first');
|
||||
logger.log[level]('second');
|
||||
expect(logger.getRecentLogs(1).map((entry) => entry.message)).toEqual(['second']);
|
||||
expect(logger.getLogSnapshot().map((entry) => entry.message)).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves ID gaps and file routing without publishing file-only entries', async () => {
|
||||
const logger = await setup();
|
||||
vi.stubEnv('SNOWLUMA_LOG_FILE_LEVEL', 'debug');
|
||||
vi.resetModules();
|
||||
const fresh = await import('../src/logger');
|
||||
fresh.setLogLevel('info');
|
||||
const entries: import('../src/logger').LogEntry[] = [];
|
||||
const unsubscribe = fresh.subscribeLogs((entry) => entries.push(entry));
|
||||
const log = fresh.createLogger('FileOnly').child({ uin: 10001 });
|
||||
log.info('before');
|
||||
fresh.runWithRequestId(42, () => log.debug('password=secret file-only\x07'));
|
||||
log.info('after');
|
||||
unsubscribe();
|
||||
|
||||
expect(entries.map((entry) => entry.message)).toEqual(['before', 'after']);
|
||||
expect(entries[1]!.id).toBe(entries[0]!.id + 2);
|
||||
expect(fresh.getLogSnapshot()).toEqual(entries);
|
||||
expect(fileWrite).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\[req#42\] password=\*\*\* file-only/),
|
||||
10001,
|
||||
);
|
||||
expect(process.stdout.write).toHaveBeenCalledTimes(2);
|
||||
expect(logger.getLogSnapshot()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { externalFileLevel, externalLogLevel, fileWriteSpy } = vi.hoisted(() => {
|
||||
const savedFileLevel = process.env.SNOWLUMA_LOG_FILE_LEVEL;
|
||||
const savedLogLevel = process.env.SNOWLUMA_LOG_LEVEL;
|
||||
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
|
||||
delete process.env.SNOWLUMA_LOG_LEVEL;
|
||||
return {
|
||||
externalFileLevel: savedFileLevel,
|
||||
externalLogLevel: savedLogLevel,
|
||||
fileWriteSpy: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Capture file transport writes via a spy so tests can assert on what reaches
|
||||
// the file.
|
||||
vi.mock('../src/log-file-transport', () => ({
|
||||
getFileTransport: () => ({ write: fileWriteSpy, close: async () => {} }),
|
||||
}));
|
||||
|
||||
import { createLogger, subscribeLogs, type LogEntry } from '../src/logger';
|
||||
|
||||
// Regression for issue #162: a hook-reported garbage UIN (13-digit, timestamp
|
||||
// shaped) produced a `[…]` tag wider than the fixed UIN slot, and the colored
|
||||
// render path did `' '.repeat(slot - tagLen)` → RangeError: Invalid count value
|
||||
// → uncaughtException. The padding must clamp at zero.
|
||||
describe('logger UIN slot padding', () => {
|
||||
let prevTTY: boolean | undefined;
|
||||
let prevNoColor: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevTTY = process.stdout.isTTY;
|
||||
prevNoColor = process.env.NO_COLOR;
|
||||
// Force the colored render path (the only one that used .repeat()).
|
||||
(process.stdout as unknown as { isTTY: boolean }).isTTY = true;
|
||||
delete process.env.NO_COLOR;
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(process.stdout as unknown as { isTTY: boolean | undefined }).isTTY = prevTTY;
|
||||
|
||||
if (prevNoColor === undefined) {
|
||||
delete process.env.NO_COLOR;
|
||||
} else {
|
||||
process.env.NO_COLOR = prevNoColor;
|
||||
}
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not throw when the UIN tag exceeds the slot width', () => {
|
||||
const log = createLogger('Test').child({ uin: '1701414379536' }); // 13-digit → [..] = 15 chars
|
||||
expect(() => log.info('phantom account line')).not.toThrow();
|
||||
expect(() => log.error('phantom error line')).not.toThrow();
|
||||
});
|
||||
|
||||
it('still renders a normal-width UIN and a no-UIN logger', () => {
|
||||
expect(() => createLogger('Test').child({ uin: '10001' }).info('ok')).not.toThrow();
|
||||
expect(() => createLogger('Test').info('no uin')).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw when the console stream cannot accept the line', () => {
|
||||
vi.mocked(process.stderr.write).mockImplementation(() => {
|
||||
throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' });
|
||||
});
|
||||
expect(() => createLogger('Test').error('disk is full')).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps structured subscriber lines plain while terminal output stays colored', () => {
|
||||
const captured: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
|
||||
|
||||
createLogger('WebUI.Export').info('download me');
|
||||
unsubscribe();
|
||||
|
||||
expect(captured).toHaveLength(1);
|
||||
expect(captured[0]!.line).toMatch(/INFO\s+\[WebUI\.Export\] download me$/);
|
||||
expect(process.stdout.write).toHaveBeenCalledWith(expect.stringContaining('\x1b['));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Independent file-level filtering ─────────────────────────────────────
|
||||
|
||||
const SAVED_FILE_LEVEL = externalFileLevel;
|
||||
|
||||
async function loadLoggerForFileLevel(level?: string) {
|
||||
if (level === undefined) {
|
||||
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
|
||||
} else {
|
||||
process.env.SNOWLUMA_LOG_FILE_LEVEL = level;
|
||||
}
|
||||
|
||||
vi.resetModules();
|
||||
fileWriteSpy.mockClear();
|
||||
return import('../src/logger');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (SAVED_FILE_LEVEL === undefined) {
|
||||
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
|
||||
} else {
|
||||
process.env.SNOWLUMA_LOG_FILE_LEVEL = SAVED_FILE_LEVEL;
|
||||
}
|
||||
|
||||
vi.resetModules();
|
||||
fileWriteSpy.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (SAVED_FILE_LEVEL === undefined) {
|
||||
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
|
||||
} else {
|
||||
process.env.SNOWLUMA_LOG_FILE_LEVEL = SAVED_FILE_LEVEL;
|
||||
}
|
||||
|
||||
if (externalLogLevel === undefined) {
|
||||
delete process.env.SNOWLUMA_LOG_LEVEL;
|
||||
} else {
|
||||
process.env.SNOWLUMA_LOG_LEVEL = externalLogLevel;
|
||||
}
|
||||
});
|
||||
|
||||
describe('file output gating', () => {
|
||||
const cases = [
|
||||
{ level: undefined, expected: ['debug', 'info', 'success', 'warn', 'error'] },
|
||||
{ level: 'debug', expected: ['debug', 'info', 'success', 'warn', 'error'] },
|
||||
{ level: 'info', expected: ['info', 'success', 'warn', 'error'] },
|
||||
{ level: 'success', expected: ['success', 'warn', 'error'] },
|
||||
{ level: 'warn', expected: ['warn', 'error'] },
|
||||
{ level: 'error', expected: ['error'] },
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
it(`writes exactly the enabled levels for ${testCase.level ?? 'the default'}`, async () => {
|
||||
const fresh = await loadLoggerForFileLevel(testCase.level);
|
||||
const log = fresh.createLogger('Test');
|
||||
|
||||
fresh.setLogLevel('trace');
|
||||
log.trace('trace');
|
||||
log.debug('debug');
|
||||
log.info('info');
|
||||
log.success('success');
|
||||
log.warn('warn');
|
||||
log.error('error');
|
||||
|
||||
const messages = fileWriteSpy.mock.calls.map(([line]) =>
|
||||
String(line).split('] ').at(-1),
|
||||
);
|
||||
expect(messages).toEqual(testCase.expected);
|
||||
});
|
||||
}
|
||||
|
||||
it('accepts surrounding whitespace and mixed case', async () => {
|
||||
const fresh = await loadLoggerForFileLevel(' INFO ');
|
||||
const log = fresh.createLogger('Test');
|
||||
|
||||
log.debug('debug');
|
||||
log.info('info');
|
||||
|
||||
expect(fileWriteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fileWriteSpy).toHaveBeenCalledWith(expect.stringContaining('info'), undefined);
|
||||
});
|
||||
|
||||
it.each(['trace', 'verbose', 'erorr'])(
|
||||
'rejects unsupported file level %s at startup',
|
||||
async (level) => {
|
||||
await expect(loadLoggerForFileLevel(level)).rejects.toThrow(
|
||||
'SNOWLUMA_LOG_FILE_LEVEL must be one of: debug, info, success, warn, error',
|
||||
);
|
||||
expect(fileWriteSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not let the runtime console level change file output', async () => {
|
||||
const fresh = await loadLoggerForFileLevel('info');
|
||||
const log = fresh.createLogger('Test');
|
||||
|
||||
fresh.setLogLevel('error');
|
||||
log.info('persisted-info');
|
||||
fresh.setLogLevel('trace');
|
||||
log.debug('filtered-debug');
|
||||
|
||||
expect(fileWriteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fileWriteSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('persisted-info'),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips rendering when both console and file filters reject a record', async () => {
|
||||
const fresh = await loadLoggerForFileLevel('error');
|
||||
fresh.setLogLevel('error');
|
||||
const rendered = vi.fn(() => 'expensive');
|
||||
const value = { [Symbol.toPrimitive]: rendered };
|
||||
|
||||
fresh.createLogger('Test').debug('value=%s', value);
|
||||
|
||||
expect(rendered).not.toHaveBeenCalled();
|
||||
expect(fileWriteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps trace out of files in an explicit file-threshold check', async () => {
|
||||
const fresh = await loadLoggerForFileLevel('debug');
|
||||
fresh.setLogLevel('trace');
|
||||
|
||||
fresh.createLogger('Test').trace('full chain');
|
||||
|
||||
expect(fileWriteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts authentication assignments from ordinary logs but not TRACE', async () => {
|
||||
const fresh = await loadLoggerForFileLevel('debug');
|
||||
fresh.setLogLevel('trace');
|
||||
const entries: LogEntry[] = [];
|
||||
const unsubscribe = fresh.subscribeLogs((entry) => entries.push(entry));
|
||||
const log = fresh.createLogger('Test');
|
||||
|
||||
log.info(
|
||||
'password=%s Authorization: Bearer %s Cookie=%s token=%s safe=%s',
|
||||
'ordinary-password',
|
||||
'ordinary-authorization',
|
||||
'ordinary-cookie',
|
||||
'ordinary-token',
|
||||
'visible',
|
||||
);
|
||||
log.trace(
|
||||
'password=%s Authorization: Bearer %s Cookie=%s token=%s',
|
||||
'trace-password',
|
||||
'trace-authorization',
|
||||
'trace-cookie',
|
||||
'trace-token',
|
||||
);
|
||||
log.info(
|
||||
`json={"authorization":"Basic json-auth"} object={ 'api-key': 'api-secret' } Authorization: Basic header-auth Cookie=session=cookie-secret; Path=/; HttpOnly`,
|
||||
);
|
||||
log.info(
|
||||
'headers.authorization=Basic dotted-auth request.token=dotted-token ' +
|
||||
'GET /callback?access_token=query-secret&x=1 ' +
|
||||
'url=https://host/?api_key=url-secret',
|
||||
);
|
||||
log.info(
|
||||
'Authorization: Digest username="alice", response="digest-secret"\n' +
|
||||
'authorization=AWS4-HMAC-SHA256 Credential=AKID, Signature=aws-secret',
|
||||
);
|
||||
log.info(
|
||||
'authToken=camel-auth idToken=camel-id clientSecret=camel-client ' +
|
||||
`json={"userPassword":"camel-password"}`,
|
||||
);
|
||||
log.info('_token=underscore-secret --password=cli-secret 2faToken=two-factor-secret');
|
||||
log.info('Authorization: *** marker-secret');
|
||||
log.trace(
|
||||
`json={"authorization":"Basic trace-json-auth"} Authorization: Basic trace-header-auth Cookie=session=trace-cookie; Path=/`,
|
||||
);
|
||||
unsubscribe();
|
||||
|
||||
const ordinaryEntries = entries.filter((entry) => entry.level === 'info');
|
||||
const ordinary = ordinaryEntries.map((entry) => entry.message).join('\n');
|
||||
expect(ordinary).toContain('password=***');
|
||||
expect(ordinary).toContain('Authorization: ***');
|
||||
expect(ordinary).toContain('Cookie=***');
|
||||
expect(ordinary).toContain('token=***');
|
||||
expect(ordinary).toContain('headers.authorization=***');
|
||||
expect(ordinary).toContain('request.token=***');
|
||||
expect(ordinary).toContain('/callback?access_token=***&x=1');
|
||||
expect(ordinary).toContain('https://host/?api_key=***');
|
||||
expect(ordinary).toContain('Authorization: ***\nauthorization=***');
|
||||
expect(ordinary).toContain('authToken=***');
|
||||
expect(ordinary).toContain('idToken=***');
|
||||
expect(ordinary).toContain('clientSecret=***');
|
||||
expect(ordinary).toContain(`json={"userPassword":***}`);
|
||||
expect(ordinary).toContain('_token=***');
|
||||
expect(ordinary).toContain('--password=***');
|
||||
expect(ordinary).toContain('2faToken=***');
|
||||
expect(ordinary).toContain('Authorization: ***');
|
||||
expect(ordinary).toContain('safe=visible');
|
||||
expect(ordinary).not.toMatch(
|
||||
/ordinary-password|ordinary-authorization|ordinary-cookie|ordinary-token|json-auth|api-secret|header-auth|cookie-secret|dotted-auth|dotted-token|query-secret|url-secret|digest-secret|AKID|aws-secret|camel-auth|camel-id|camel-client|camel-password|underscore-secret|cli-secret|two-factor-secret|marker-secret/,
|
||||
);
|
||||
|
||||
const trace = entries
|
||||
.filter((entry) => entry.level === 'trace')
|
||||
.map((entry) => entry.message)
|
||||
.join('\n');
|
||||
expect(trace).toContain('trace-password');
|
||||
expect(trace).toContain('trace-authorization');
|
||||
expect(trace).toContain('trace-cookie');
|
||||
expect(trace).toContain('trace-token');
|
||||
expect(trace).toContain('trace-json-auth');
|
||||
expect(trace).toContain('trace-header-auth');
|
||||
|
||||
const persisted = fileWriteSpy.mock.calls.map(([line]) => String(line)).join('\n');
|
||||
expect(persisted).not.toMatch(
|
||||
/ordinary-password|ordinary-authorization|ordinary-cookie|ordinary-token|json-auth|api-secret|header-auth|cookie-secret|dotted-auth|dotted-token|query-secret|url-secret|digest-secret|AKID|aws-secret|trace-password|trace-json-auth/,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the complete retained normal and TRACE snapshot in record order', async () => {
|
||||
const fresh = await loadLoggerForFileLevel('debug');
|
||||
const log = fresh.createLogger('Export');
|
||||
fresh.setLogLevel('trace');
|
||||
|
||||
log.info('normal-before');
|
||||
log.trace('trace-hex=001122aabbcc');
|
||||
log.warn('normal-after');
|
||||
|
||||
expect(fresh.getRecentLogs(1).map((entry) => entry.message)).toEqual(['normal-after']);
|
||||
expect(fresh.getLogSnapshot().map((entry) => entry.message)).toEqual([
|
||||
'normal-before',
|
||||
'trace-hex=001122aabbcc',
|
||||
'normal-after',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prints bootstrap credentials only to the current process terminal', async () => {
|
||||
const fresh = await loadLoggerForFileLevel('error');
|
||||
fresh.setLogLevel('error');
|
||||
const entries: LogEntry[] = [];
|
||||
const unsubscribe = fresh.subscribeLogs((entry) => entries.push(entry));
|
||||
const snapshotBefore = fresh.getLogSnapshot();
|
||||
const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
|
||||
try {
|
||||
fresh.logInitialWebuiCredentials('secret');
|
||||
|
||||
expect(stdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('initial credentials: user=admin password=secret'),
|
||||
);
|
||||
expect(entries).toEqual([]);
|
||||
expect(fresh.getLogSnapshot()).toEqual(snapshotBefore);
|
||||
expect(fileWriteSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { currentRequestId, nextRequestId, runWithRequestId } from '../src/request-context';
|
||||
|
||||
describe('request-context', () => {
|
||||
it('nextRequestId increments monotonically and stays a positive integer', () => {
|
||||
const a = nextRequestId();
|
||||
const b = nextRequestId();
|
||||
expect(a).toBeGreaterThan(0);
|
||||
expect(b).toBe(a + 1);
|
||||
});
|
||||
|
||||
it('runWithRequestId binds the id across sync + async, with nested scopes restoring', async () => {
|
||||
expect(currentRequestId()).toBeUndefined();
|
||||
|
||||
runWithRequestId(42, () => {
|
||||
expect(currentRequestId()).toBe(42);
|
||||
runWithRequestId(99, () => {
|
||||
expect(currentRequestId()).toBe(99);
|
||||
});
|
||||
expect(currentRequestId()).toBe(42); // outer scope restored after the nested one
|
||||
});
|
||||
|
||||
expect(currentRequestId()).toBeUndefined(); // cleared outside any scope
|
||||
|
||||
await runWithRequestId(7, async () => {
|
||||
await Promise.resolve();
|
||||
expect(currentRequestId()).toBe(7); // survives awaits in the same async chain
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the callback result', () => {
|
||||
expect(runWithRequestId(1, () => 'value')).toBe('value');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { normalizeRuntimeConfig, resolveRuntimeEnvOverrides } from '../src/runtime';
|
||||
|
||||
describe('normalizeRuntimeConfig', () => {
|
||||
it('applies all defaults for an empty object', () => {
|
||||
expect(normalizeRuntimeConfig({})).toEqual({
|
||||
webuiPort: 5099,
|
||||
hookAutoLoad: false,
|
||||
webuiHost: '127.0.0.1',
|
||||
webuiTls: { enabled: false },
|
||||
trustProxy: '',
|
||||
logMaxTotalMb: 1024,
|
||||
logRetainDays: 7,
|
||||
logPerUin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through valid values', () => {
|
||||
expect(
|
||||
normalizeRuntimeConfig({
|
||||
webuiPort: 8080,
|
||||
hookAutoLoad: true,
|
||||
webuiHost: '127.0.0.1',
|
||||
webuiTls: { enabled: true },
|
||||
trustProxy: '1',
|
||||
logMaxTotalMb: 2048,
|
||||
logRetainDays: 0,
|
||||
logPerUin: true,
|
||||
}),
|
||||
).toEqual({
|
||||
webuiPort: 8080,
|
||||
hookAutoLoad: true,
|
||||
webuiHost: '127.0.0.1',
|
||||
webuiTls: { enabled: true },
|
||||
trustProxy: '1',
|
||||
logMaxTotalMb: 2048,
|
||||
logRetainDays: 0,
|
||||
logPerUin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back on invalid types', () => {
|
||||
const out = normalizeRuntimeConfig({
|
||||
webuiPort: 0, // out of range → default
|
||||
webuiHost: 123, // non-string → default
|
||||
webuiTls: 'yes', // non-object → default
|
||||
trustProxy: 5, // non-string → default
|
||||
});
|
||||
expect(out.webuiPort).toBe(5099);
|
||||
expect(out.webuiHost).toBe('127.0.0.1');
|
||||
expect(out.webuiTls).toEqual({ enabled: false });
|
||||
expect(out.trustProxy).toBe('');
|
||||
});
|
||||
|
||||
it('fails fast on invalid persisted log settings', () => {
|
||||
expect(() => normalizeRuntimeConfig({ logMaxTotalMb: 0 })).toThrow(/logMaxTotalMb/);
|
||||
expect(() => normalizeRuntimeConfig({ logRetainDays: -1 })).toThrow(/logRetainDays/);
|
||||
expect(() => normalizeRuntimeConfig({ logPerUin: 'maybe' })).toThrow(/logPerUin/);
|
||||
expect(() => normalizeRuntimeConfig({ logPerUin: '' })).toThrow(/logPerUin/);
|
||||
expect(() => normalizeRuntimeConfig({ logMaxTotalMb: Number.MAX_SAFE_INTEGER })).toThrow(
|
||||
/logMaxTotalMb/,
|
||||
);
|
||||
expect(() => normalizeRuntimeConfig({ logRetainDays: Number.MAX_SAFE_INTEGER })).toThrow(
|
||||
/logRetainDays/,
|
||||
);
|
||||
});
|
||||
|
||||
it('coerces webuiTls.enabled loosely and trims a blank host to default', () => {
|
||||
expect(normalizeRuntimeConfig({ webuiTls: { enabled: 1 } }).webuiTls).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(normalizeRuntimeConfig({ webuiHost: ' ' }).webuiHost).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('rejects a non-object input back to full defaults', () => {
|
||||
expect(normalizeRuntimeConfig(null).webuiPort).toBe(5099);
|
||||
expect(normalizeRuntimeConfig('nope').webuiHost).toBe('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimeEnvOverrides', () => {
|
||||
it('returns empty when no SNOWLUMA_* vars are set', () => {
|
||||
expect(resolveRuntimeEnvOverrides({})).toEqual({});
|
||||
});
|
||||
|
||||
it('parses port / host / trustProxy from env', () => {
|
||||
expect(
|
||||
resolveRuntimeEnvOverrides({
|
||||
SNOWLUMA_WEBUI_PORT: '6700',
|
||||
SNOWLUMA_WEBUI_HOST: '127.0.0.1',
|
||||
SNOWLUMA_WEBUI_TRUST_PROXY: '1',
|
||||
}),
|
||||
).toEqual({ webuiPort: 6700, webuiHost: '127.0.0.1', trustProxy: '1' });
|
||||
});
|
||||
|
||||
it('parses log storage overrides including retainDays=0', () => {
|
||||
expect(
|
||||
resolveRuntimeEnvOverrides({
|
||||
SNOWLUMA_LOG_MAX_TOTAL_MB: '4096',
|
||||
SNOWLUMA_LOG_RETAIN_DAYS: '0',
|
||||
SNOWLUMA_LOG_PER_UIN: '1',
|
||||
}),
|
||||
).toEqual({
|
||||
logMaxTotalMb: 4096,
|
||||
logRetainDays: 0,
|
||||
logPerUin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails fast on invalid log storage overrides', () => {
|
||||
expect(() =>
|
||||
resolveRuntimeEnvOverrides({
|
||||
SNOWLUMA_LOG_MAX_TOTAL_MB: '0',
|
||||
}),
|
||||
).toThrow(/SNOWLUMA_LOG_MAX_TOTAL_MB/);
|
||||
expect(() =>
|
||||
resolveRuntimeEnvOverrides({
|
||||
SNOWLUMA_LOG_RETAIN_DAYS: '-1',
|
||||
}),
|
||||
).toThrow(/SNOWLUMA_LOG_RETAIN_DAYS/);
|
||||
expect(() =>
|
||||
resolveRuntimeEnvOverrides({
|
||||
SNOWLUMA_LOG_PER_UIN: 'maybe',
|
||||
}),
|
||||
).toThrow(/SNOWLUMA_LOG_PER_UIN/);
|
||||
});
|
||||
|
||||
it('fails fast when converted log storage overrides would be unsafe', () => {
|
||||
expect(() =>
|
||||
resolveRuntimeEnvOverrides({
|
||||
SNOWLUMA_LOG_MAX_TOTAL_MB: String(Number.MAX_SAFE_INTEGER),
|
||||
}),
|
||||
).toThrow(/SNOWLUMA_LOG_MAX_TOTAL_MB/);
|
||||
expect(() =>
|
||||
resolveRuntimeEnvOverrides({
|
||||
SNOWLUMA_LOG_RETAIN_DAYS: String(Number.MAX_SAFE_INTEGER),
|
||||
}),
|
||||
).toThrow(/SNOWLUMA_LOG_RETAIN_DAYS/);
|
||||
});
|
||||
|
||||
it('parses SNOWLUMA_WEBUI_TLS over the saved switch', () => {
|
||||
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: '1' })).toEqual({
|
||||
webuiTls: { enabled: true },
|
||||
});
|
||||
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: '0' })).toEqual({
|
||||
webuiTls: { enabled: false },
|
||||
});
|
||||
expect(() => resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: 'maybe' })).toThrow(
|
||||
/SNOWLUMA_WEBUI_TLS/,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores an out-of-range / non-numeric port env', () => {
|
||||
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: '0' })).toEqual({});
|
||||
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: 'abc' })).toEqual({});
|
||||
});
|
||||
|
||||
it('treats trustProxy="0"/"off" as a real override (not absent)', () => {
|
||||
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TRUST_PROXY: '0' })).toEqual({
|
||||
trustProxy: '0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRuntimeConfig (fs)', () => {
|
||||
let prevCwd: string;
|
||||
let dir: string;
|
||||
let prevPortEnv: string | undefined;
|
||||
beforeEach(async () => {
|
||||
const fs = await import('fs');
|
||||
const os = await import('os');
|
||||
const path = await import('path');
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sl-rt-'));
|
||||
prevCwd = process.cwd();
|
||||
process.chdir(dir);
|
||||
prevPortEnv = process.env.SNOWLUMA_WEBUI_PORT;
|
||||
delete process.env.SNOWLUMA_WEBUI_PORT;
|
||||
});
|
||||
afterEach(async () => {
|
||||
process.chdir(prevCwd);
|
||||
|
||||
if (prevPortEnv === undefined) {
|
||||
delete process.env.SNOWLUMA_WEBUI_PORT;
|
||||
} else {
|
||||
process.env.SNOWLUMA_WEBUI_PORT = prevPortEnv;
|
||||
}
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('merges a patch over on-disk values and persists normalized', async () => {
|
||||
const { updateRuntimeConfig } = await import('../src/runtime');
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const out = updateRuntimeConfig({ webuiHost: '127.0.0.1', webuiTls: { enabled: true } });
|
||||
expect(out.webuiHost).toBe('127.0.0.1');
|
||||
expect(out.webuiTls).toEqual({ enabled: true });
|
||||
const onDisk = JSON.parse(fs.readFileSync(path.join('config', 'runtime.json'), 'utf8'));
|
||||
expect(onDisk.webuiHost).toBe('127.0.0.1');
|
||||
expect(onDisk.webuiTls).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('does NOT bake an env-overridden port into the persisted file', async () => {
|
||||
const { updateRuntimeConfig } = await import('../src/runtime');
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
process.env.SNOWLUMA_WEBUI_PORT = '9999';
|
||||
updateRuntimeConfig({ webuiHost: '127.0.0.1' });
|
||||
const onDisk = JSON.parse(fs.readFileSync(path.join('config', 'runtime.json'), 'utf8'));
|
||||
expect(onDisk.webuiPort).toBe(5099); // default on-disk, NOT the env 9999
|
||||
});
|
||||
|
||||
it('keeps the previous runtime file intact when atomic replacement fails', async () => {
|
||||
const { updateRuntimeConfig } = await import('../src/runtime');
|
||||
updateRuntimeConfig({ logRetainDays: 7 });
|
||||
const runtimePath = path.join('config', 'runtime.json');
|
||||
const before = fs.readFileSync(runtimePath, 'utf8');
|
||||
vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error('disk replacement failed'), { code: 'EIO' });
|
||||
});
|
||||
|
||||
expect(() => updateRuntimeConfig({ logRetainDays: 0 })).toThrow(/disk replacement failed/);
|
||||
expect(fs.readFileSync(runtimePath, 'utf8')).toBe(before);
|
||||
expect(fs.readdirSync('config').filter((name) => name.includes('.tmp-'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareVersions, parseVersion } from '../src/semver';
|
||||
|
||||
describe('parseVersion', () => {
|
||||
it.each([
|
||||
'0.0.0',
|
||||
'1.2.3',
|
||||
'1.2.3-rc.1',
|
||||
'1.2.3-alpha-beta',
|
||||
'1.2.3+001.build',
|
||||
'1.2.3-rc.1+build.2',
|
||||
])('accepts canonical SemVer and preserves its full value: %s', (version) => {
|
||||
expect(parseVersion(version)?.raw).toBe(version);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
123,
|
||||
{},
|
||||
'',
|
||||
'1.2',
|
||||
'1.2.3.4',
|
||||
'1.2.x',
|
||||
'01.2.3',
|
||||
'1.02.3',
|
||||
'1.2.03',
|
||||
'1.2.3-01',
|
||||
'1.2.3-rc.01',
|
||||
'1.2.3-rc..1',
|
||||
'1.2.3-rc_1',
|
||||
'1.2.3+build..1',
|
||||
'1.2.3+build_1',
|
||||
'v1.2.3',
|
||||
'V1.2.3',
|
||||
'=1.2.3',
|
||||
' 1.2.3',
|
||||
'1.2.3\n',
|
||||
])('rejects noncanonical input without coercion: %j', (version) => {
|
||||
expect(parseVersion(version)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareVersions', () => {
|
||||
it.each([
|
||||
['1.2.3', '1.2.3', 0],
|
||||
['1.2.4', '1.2.3', 1],
|
||||
['1.2.3', '1.2.4', -1],
|
||||
['1.10.0', '1.9.0', 1],
|
||||
['2.0.0', '1.9.9', 1],
|
||||
['3.0.0', '1.0.0', 1],
|
||||
['1.0.0', '3.0.0', -1],
|
||||
['1.2.3', '1.2.3-rc.1', 1],
|
||||
['1.2.3-rc.1', '1.2.3', -1],
|
||||
['1.2.3-rc.1', '1.2.3-rc.1', 0],
|
||||
['1.2.3-rc.1', '1.2.3-rc.2', -1],
|
||||
['1.2.3-rc.2', '1.2.3-rc.1', 1],
|
||||
['1.2.3-rc.10', '1.2.3-rc.2', 1],
|
||||
['1.2.3-rc.1+build.2', '1.2.3-rc.1+build.1', 0],
|
||||
['1.2.3-alpha-beta', '1.2.3-alpha', 1],
|
||||
['2.0.0-rc.1', '1.9.9', 1],
|
||||
] as const)('compareVersions(%j, %j) === %s', (a, b, expected) => {
|
||||
expect(compareVersions(a, b)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(['1.2', '1.2.3.9', '1.2.x', '', 'v1.2.3', 'V1.2.3', '01.2.3', '1.2.3-rc.01'])(
|
||||
'rejects invalid versions instead of guessing: %j',
|
||||
(version) => {
|
||||
expect(() => compareVersions(version, '1.2.3')).toThrow();
|
||||
expect(() => compareVersions('1.2.3', version)).toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { createSingleFlightCache } from '../src/single-flight-cache';
|
||||
|
||||
/** A promise whose resolve/reject is exposed, for driving single-flight races. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('SingleFlightCache', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('disposes retained values and rejects new reads without restarting loads or timers', async () => {
|
||||
vi.useFakeTimers();
|
||||
const load = vi.fn(async (key: string) => ({ key }));
|
||||
const cache = createSingleFlightCache({ ttlMs: 300_000, load });
|
||||
await cache.get('first');
|
||||
await cache.get('second');
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
cache.dispose();
|
||||
cache.dispose();
|
||||
cache.clear();
|
||||
|
||||
expect(cache.size).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await expect(cache.get('first')).rejects.toThrow('disposed');
|
||||
await expect(cache.get('new', { force: true })).rejects.toThrow('disposed');
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(['resolve', 'reject'] as const)(
|
||||
'preserves a pending %s after disposal without retaining the result',
|
||||
async (outcome) => {
|
||||
vi.useFakeTimers();
|
||||
const pending = deferred<string>();
|
||||
const cache = createSingleFlightCache({ ttlMs: 300_000, load: () => pending.promise });
|
||||
const first = cache.get('key');
|
||||
expect(cache.get('key')).toBe(first);
|
||||
cache.dispose();
|
||||
|
||||
if (outcome === 'resolve') {
|
||||
pending.resolve('late value');
|
||||
await expect(first).resolves.toBe('late value');
|
||||
} else {
|
||||
pending.reject(new Error('late failure'));
|
||||
await expect(first).rejects.toThrow('late failure');
|
||||
}
|
||||
|
||||
expect(cache.size).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('releases expired values while idle without expiring newer values early', async () => {
|
||||
vi.useFakeTimers();
|
||||
const load = vi.fn(async (key: string) => ({ key }));
|
||||
const cache = createSingleFlightCache({ ttlMs: 1000, load });
|
||||
await cache.get('old');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
const newer = await cache.get('new');
|
||||
|
||||
expect(cache.size).toBe(2);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(cache.size).toBe(1);
|
||||
expect(await cache.get('new')).toBe(newer);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(cache.size).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a refreshed entry until its new deadline and expires other keys', async () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = createSingleFlightCache({ ttlMs: 1000, load: async (key: string) => key });
|
||||
await cache.get('refreshed');
|
||||
await cache.get('old');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await cache.get('refreshed', { force: true });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(cache.size).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it.each(['invalidate', 'clear'] as const)(
|
||||
'%s cancels idle cleanup when the last value is removed',
|
||||
async (operation) => {
|
||||
vi.useFakeTimers();
|
||||
const cache = createSingleFlightCache({ ttlMs: 1000, load: async () => 'value' });
|
||||
await cache.get('key');
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
cache[operation]('key');
|
||||
expect(cache.size).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
await cache.get('key');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(cache.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not let expiration detach a pending refresh', async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = deferred<string>();
|
||||
const load = vi.fn().mockResolvedValueOnce('old').mockReturnValue(pending.promise);
|
||||
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
|
||||
await cache.get('key');
|
||||
const refresh = cache.get('key', { force: true });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(cache.size).toBe(0);
|
||||
expect(cache.get('key')).toBe(refresh);
|
||||
pending.resolve('new');
|
||||
await expect(refresh).resolves.toBe('new');
|
||||
expect(cache.size).toBe(1);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not retain values when caching is disabled by a zero TTL', async () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = createSingleFlightCache({ ttlMs: 0, load: async () => 'value' });
|
||||
await expect(cache.get('key')).resolves.toBe('value');
|
||||
expect(cache.size).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each(['invalidate', 'clear'] as const)(
|
||||
'%s detaches pending loads from the cache',
|
||||
async (operation) => {
|
||||
const old = deferred<string>();
|
||||
const current = deferred<string>();
|
||||
const load = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(old.promise)
|
||||
.mockReturnValueOnce(current.promise);
|
||||
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
|
||||
const first = cache.get('key');
|
||||
|
||||
cache[operation]('key');
|
||||
const second = cache.get('key');
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
|
||||
old.resolve('old');
|
||||
await expect(first).resolves.toBe('old');
|
||||
expect(cache.get('key')).toBe(second);
|
||||
|
||||
current.resolve('current');
|
||||
await expect(second).resolves.toBe('current');
|
||||
await expect(cache.get('key')).resolves.toBe('current');
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not let a late invalidated result overwrite a newer value', async () => {
|
||||
const old = deferred<string>();
|
||||
const load = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue('current');
|
||||
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
|
||||
const first = cache.get('key');
|
||||
cache.invalidate('key');
|
||||
|
||||
await expect(cache.get('key')).resolves.toBe('current');
|
||||
old.resolve('old');
|
||||
await first;
|
||||
await expect(cache.get('key')).resolves.toBe('current');
|
||||
});
|
||||
|
||||
it('caches within the TTL and reloads once expired', async () => {
|
||||
let clock = 1000;
|
||||
const load = vi.fn(async (k: string) => `${k}@${clock}`);
|
||||
const cache = createSingleFlightCache<string, string>({
|
||||
ttlMs: 100,
|
||||
load,
|
||||
now: () => clock,
|
||||
});
|
||||
|
||||
expect(await cache.get('a')).toBe('a@1000');
|
||||
clock = 1050; // still fresh (< 100)
|
||||
expect(await cache.get('a')).toBe('a@1000');
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
|
||||
clock = 1101; // 101ms since store → expired
|
||||
expect(await cache.get('a')).toBe('a@1101');
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('de-dupes concurrent callers onto a single load (single-flight)', async () => {
|
||||
const d = deferred<string>();
|
||||
const load = vi.fn(() => d.promise);
|
||||
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
|
||||
|
||||
const p1 = cache.get('k');
|
||||
const p2 = cache.get('k');
|
||||
const p3 = cache.get('k');
|
||||
expect(load).toHaveBeenCalledTimes(1); // only one load in flight
|
||||
|
||||
d.resolve('v');
|
||||
expect(await Promise.all([p1, p2, p3])).toEqual(['v', 'v', 'v']);
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keys independently — different keys load separately', async () => {
|
||||
const load = vi.fn(async (k: number) => k * 2);
|
||||
const cache = createSingleFlightCache<number, number>({ ttlMs: 1000, load });
|
||||
expect(await cache.get(1)).toBe(2);
|
||||
expect(await cache.get(2)).toBe(4);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('force bypasses the freshness check but still joins an in-flight load', async () => {
|
||||
let clock = 0;
|
||||
const load = vi.fn(async (k: string) => `${k}@${clock}`);
|
||||
const cache = createSingleFlightCache<string, string>({
|
||||
ttlMs: 10_000,
|
||||
load,
|
||||
now: () => clock,
|
||||
});
|
||||
|
||||
expect(await cache.get('a')).toBe('a@0'); // 1 load, cached fresh
|
||||
clock = 5; // still fresh
|
||||
expect(await cache.get('a', { force: true })).toBe('a@5'); // force → reload despite fresh
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
|
||||
// force still single-flights: two concurrent forced gets share one load.
|
||||
const d = deferred<string>();
|
||||
load.mockImplementationOnce(() => d.promise);
|
||||
const p1 = cache.get('a', { force: true });
|
||||
const p2 = cache.get('a', { force: true });
|
||||
expect(load).toHaveBeenCalledTimes(3); // not 4
|
||||
d.resolve('shared');
|
||||
expect(await Promise.all([p1, p2])).toEqual(['shared', 'shared']);
|
||||
});
|
||||
|
||||
it('shouldCache=false leaves the entry uncached (next call reloads) but still de-dupes in-flight', async () => {
|
||||
let n = 0;
|
||||
const load = vi.fn(async () => ({ error: true as boolean, n: ++n }));
|
||||
const cache = createSingleFlightCache({
|
||||
ttlMs: 10_000,
|
||||
load,
|
||||
shouldCache: (v) => !v.error,
|
||||
});
|
||||
|
||||
// concurrent callers still share one load...
|
||||
const [a, b] = await Promise.all([cache.get('k'), cache.get('k')]);
|
||||
expect(a).toEqual({ error: true, n: 1 });
|
||||
expect(b).toEqual({ error: true, n: 1 });
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
|
||||
// ...but nothing was cached, so the next call reloads.
|
||||
expect(await cache.get('k')).toEqual({ error: true, n: 2 });
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
|
||||
// a good result IS cached.
|
||||
load.mockImplementationOnce(async () => ({ error: false, n: ++n }));
|
||||
const good = await cache.get('k');
|
||||
expect(good).toEqual({ error: false, n: 3 });
|
||||
expect(await cache.get('k')).toEqual({ error: false, n: 3 }); // served from cache
|
||||
expect(load).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('a rejected load propagates to all joined callers, is not cached, and clears in-flight', async () => {
|
||||
let attempt = 0;
|
||||
const load = vi.fn(async () => {
|
||||
attempt++;
|
||||
|
||||
if (attempt === 1) {
|
||||
throw new Error('boom');
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
});
|
||||
const cache = createSingleFlightCache<string, string>({ ttlMs: 10_000, load });
|
||||
|
||||
const p1 = cache.get('k');
|
||||
const p2 = cache.get('k');
|
||||
await expect(p1).rejects.toThrow('boom');
|
||||
await expect(p2).rejects.toThrow('boom'); // same in-flight, same rejection
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
|
||||
// in-flight cleared + not cached → next call retries and succeeds.
|
||||
expect(await cache.get('k')).toBe('ok');
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidate drops a cached entry; clear drops all', async () => {
|
||||
const load = vi.fn(async (k: string) => k.toUpperCase());
|
||||
const cache = createSingleFlightCache<string, string>({ ttlMs: 10_000, load });
|
||||
await cache.get('a');
|
||||
await cache.get('b');
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
|
||||
cache.invalidate('a');
|
||||
await cache.get('a'); // reloads
|
||||
await cache.get('b'); // still cached
|
||||
expect(load).toHaveBeenCalledTimes(3);
|
||||
|
||||
cache.clear();
|
||||
await cache.get('a');
|
||||
await cache.get('b');
|
||||
expect(load).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('allows retry when a loader throws before returning its promise', async () => {
|
||||
const load = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('failed before request');
|
||||
})
|
||||
.mockResolvedValue('recovered');
|
||||
const cache = createSingleFlightCache({ ttlMs: 1000, load });
|
||||
|
||||
await expect(cache.get('key')).rejects.toThrow('failed before request');
|
||||
await expect(cache.get('key')).resolves.toBe('recovered');
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createLogger,
|
||||
currentRequestId,
|
||||
getLogLevel,
|
||||
nextRequestId,
|
||||
renderTraceBytes,
|
||||
runWithoutRequestContext,
|
||||
runWithRequestId,
|
||||
runWithTraceRequest,
|
||||
setLogLevel,
|
||||
} from '../src/logger';
|
||||
|
||||
const originalLevel = getLogLevel();
|
||||
|
||||
afterEach(() => {
|
||||
setLogLevel(originalLevel);
|
||||
});
|
||||
|
||||
describe('TRACE diagnostics contract', () => {
|
||||
it('renders every byte as continuous lowercase hexadecimal', () => {
|
||||
expect(renderTraceBytes(new Uint8Array())).toBe('');
|
||||
expect(renderTraceBytes(new Uint8Array([0x00, 0x01, 0x0f, 0x10, 0xff]))).toBe('00010f10ff');
|
||||
});
|
||||
|
||||
it('renders only the addressed Uint8Array view', () => {
|
||||
const backing = new Uint8Array([0xaa, 0x00, 0x0f, 0xff, 0xbb]);
|
||||
|
||||
expect(renderTraceBytes(backing.subarray(1, 4))).toBe('000fff');
|
||||
expect(renderTraceBytes(Buffer.from(backing.buffer).subarray(2, 4))).toBe('0fff');
|
||||
});
|
||||
|
||||
it('does not render bytes while TRACE is disabled', () => {
|
||||
setLogLevel('info');
|
||||
let rendered = false;
|
||||
|
||||
createLogger('Trace.Contract').trace(() => {
|
||||
rendered = true;
|
||||
return ['body=%s', renderTraceBytes(new Uint8Array([0xff]))];
|
||||
});
|
||||
|
||||
expect(rendered).toBe(false);
|
||||
});
|
||||
|
||||
it('creates distinct request contexts that survive asynchronous work', async () => {
|
||||
setLogLevel('info');
|
||||
const beforeDisabled = nextRequestId();
|
||||
expect(runWithTraceRequest(() => currentRequestId())).toBeUndefined();
|
||||
const afterDisabled = nextRequestId();
|
||||
expect(afterDisabled).toBe(beforeDisabled + 1);
|
||||
|
||||
setLogLevel('trace');
|
||||
const observeContext = () =>
|
||||
runWithTraceRequest(async () => {
|
||||
const before = currentRequestId();
|
||||
await Promise.resolve();
|
||||
const after = currentRequestId();
|
||||
expect(after).toBe(before);
|
||||
return after;
|
||||
});
|
||||
|
||||
const first = await observeContext();
|
||||
expect(currentRequestId()).toBeUndefined();
|
||||
const second = await observeContext();
|
||||
expect(currentRequestId()).toBeUndefined();
|
||||
|
||||
expect(first).toEqual(expect.any(Number));
|
||||
expect(first).toBeGreaterThan(0);
|
||||
expect(second).toEqual(expect.any(Number));
|
||||
expect(second).toBeGreaterThan(0);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('allocates once for nested TRACE contexts and reuses the request id', () => {
|
||||
setLogLevel('trace');
|
||||
const before = nextRequestId();
|
||||
|
||||
const observed = runWithTraceRequest(() => {
|
||||
const outer = currentRequestId();
|
||||
const inner = runWithTraceRequest(() => currentRequestId());
|
||||
return { outer, inner };
|
||||
});
|
||||
|
||||
const after = nextRequestId();
|
||||
expect(observed.outer).toEqual(expect.any(Number));
|
||||
expect(observed.inner).toBe(observed.outer);
|
||||
expect(observed.outer).toBe(before + 1);
|
||||
expect(after).toBe(before + 2);
|
||||
expect(currentRequestId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detaches long-lived resources from the current request context', async () => {
|
||||
await runWithRequestId(4243, async () => {
|
||||
expect(currentRequestId()).toBe(4243);
|
||||
const detached = await runWithoutRequestContext(async () => {
|
||||
expect(currentRequestId()).toBeUndefined();
|
||||
await Promise.resolve();
|
||||
return currentRequestId();
|
||||
});
|
||||
expect(detached).toBeUndefined();
|
||||
expect(currentRequestId()).toBe(4243);
|
||||
});
|
||||
expect(currentRequestId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reuses an existing request context even while TRACE is disabled', async () => {
|
||||
setLogLevel('info');
|
||||
|
||||
await runWithRequestId(4242, async () => {
|
||||
const observed = await runWithTraceRequest(async () => {
|
||||
await Promise.resolve();
|
||||
return currentRequestId();
|
||||
});
|
||||
expect(observed).toBe(4242);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isRealUin } from '../src/uin';
|
||||
|
||||
describe('isRealUin', () => {
|
||||
it('accepts real QQ UINs (5..10 digits)', () => {
|
||||
expect(isRealUin('10001')).toBe(true); // 5-digit floor
|
||||
expect(isRealUin('123456789')).toBe(true);
|
||||
expect(isRealUin('4294967295')).toBe(true); // uint32 max, 10 digits
|
||||
});
|
||||
|
||||
it('rejects the empty string and "0"', () => {
|
||||
expect(isRealUin('')).toBe(false);
|
||||
expect(isRealUin('0')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects too-short UINs (< 5 digits)', () => {
|
||||
expect(isRealUin('1')).toBe(false);
|
||||
expect(isRealUin('1234')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects the garbage timestamp-shaped UINs from issue #162 (11+ digits)', () => {
|
||||
expect(isRealUin('1701414379536')).toBe(false); // 13-digit ms timestamp
|
||||
expect(isRealUin('1614019567632')).toBe(false);
|
||||
expect(isRealUin('2014024115056')).toBe(false);
|
||||
expect(isRealUin('12345678901')).toBe(false); // 11 digits — first rejected width
|
||||
});
|
||||
|
||||
it('rejects non-digit strings (path traversal / injection guards)', () => {
|
||||
expect(isRealUin('12345a')).toBe(false);
|
||||
expect(isRealUin('12 345')).toBe(false);
|
||||
expect(isRealUin('../etc')).toBe(false);
|
||||
expect(isRealUin('123456\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('guarantees a BigInt-safe pure-digit string on accept', () => {
|
||||
const uin = '123456789';
|
||||
expect(isRealUin(uin)).toBe(true);
|
||||
expect(() => BigInt(uin)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeMirrorUrl, normalizeUpdateSourceConfig } from '../src/update-source';
|
||||
import { normalizeRuntimeConfig } from '../src/runtime';
|
||||
|
||||
describe('update source configuration', () => {
|
||||
it('normalizes proxy prefixes, deduplicates mirrors and retains order', () => {
|
||||
expect(
|
||||
normalizeUpdateSourceConfig({
|
||||
mode: 'auto',
|
||||
mirror: '',
|
||||
mirrors: [
|
||||
' https://EXAMPLE.com/proxy/ ',
|
||||
'https://example.com/proxy',
|
||||
'https://other.example',
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
mode: 'auto',
|
||||
mirror: '',
|
||||
mirrors: ['https://example.com/proxy', 'https://other.example'],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'file:///tmp/x',
|
||||
'ftp://example.com',
|
||||
'https://user:secret@example.com',
|
||||
'https://example.com/?token=a',
|
||||
'https://example.com/#a',
|
||||
null,
|
||||
])('rejects invalid mirror %s', (url) => {
|
||||
expect(() => normalizeMirrorUrl(url)).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{ mode: ['auto'], mirrors: [] },
|
||||
{ mode: 'mirror', mirror: '', mirrors: [] },
|
||||
{ mode: 'auto', mirrors: Array(17).fill('https://example.com') },
|
||||
])('rejects invalid configuration %j', (config) => {
|
||||
expect(() => normalizeUpdateSourceConfig(config)).toThrow();
|
||||
});
|
||||
|
||||
it('retains configured update sources when normalizing runtime settings', () => {
|
||||
const updateSource = { mode: 'direct', mirror: '', mirrors: [] };
|
||||
expect(normalizeRuntimeConfig({ updateSource, webuiPort: 7777 })).toMatchObject({
|
||||
updateSource,
|
||||
webuiPort: 7777,
|
||||
});
|
||||
expect(normalizeRuntimeConfig({})).not.toHaveProperty('updateSource');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
defaultWebuiPresentation,
|
||||
injectWebuiTarget,
|
||||
parseWebuiPresentation,
|
||||
resolveWebuiTarget,
|
||||
} from '../src/webui-target';
|
||||
|
||||
describe('deployment presentation contract', () => {
|
||||
it('requires explicit opt-in and rejects misspelled deployments', () => {
|
||||
expect(resolveWebuiTarget(undefined)).toBeNull();
|
||||
expect(resolveWebuiTarget(' ')).toBeNull();
|
||||
expect(resolveWebuiTarget('maibot')).toBe('maibot');
|
||||
expect(() => resolveWebuiTarget('mai-bot')).toThrow('SNOWLUMA_WEBUI_TARGET');
|
||||
expect(() => resolveWebuiTarget('__proto__')).toThrow('SNOWLUMA_WEBUI_TARGET');
|
||||
});
|
||||
|
||||
it('keeps the unconfigured document intact and injects before cached appearance', () => {
|
||||
const html = '<html><head><script>readSavedAppearance()</script></head></html>';
|
||||
expect(injectWebuiTarget(html, null)).toBe(html);
|
||||
const injected = injectWebuiTarget(html, 'maibot');
|
||||
expect(injected.indexOf('snowluma-webui-target')).toBeLessThan(
|
||||
injected.indexOf('readSavedAppearance'),
|
||||
);
|
||||
expect(injected).toContain(JSON.stringify(defaultWebuiPresentation('maibot')));
|
||||
expect(() => injectWebuiTarget('broken html', 'maibot')).toThrow('missing <head>');
|
||||
});
|
||||
|
||||
it('validates the version, target, style and color mode without accepting arbitrary CSS', () => {
|
||||
const valid = defaultWebuiPresentation('maibot');
|
||||
expect(parseWebuiPresentation(valid)).toEqual(valid);
|
||||
expect(
|
||||
parseWebuiPresentation({ ...valid, mode: 'dark', css: 'body{display:none}' }),
|
||||
).toEqual({ ...valid, mode: 'dark' });
|
||||
expect(parseWebuiPresentation({ version: 1, target: null })).toEqual({
|
||||
version: 1,
|
||||
target: null,
|
||||
});
|
||||
|
||||
for (const input of [
|
||||
null,
|
||||
{},
|
||||
{ ...valid, version: 2 },
|
||||
{ ...valid, target: 'other' },
|
||||
{ ...valid, style: 'modern' },
|
||||
{ ...valid, mode: 'blue' },
|
||||
]) {
|
||||
expect(() => parseWebuiPresentation(input)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user