Files
xbin/test/config-model.spec.ts
T

313 lines
9.8 KiB
TypeScript

/**
* Unit tests for config parsing and request-model validation helpers.
*/
import { describe, expect, it } from 'vitest';
import { expireAtFromKey, resolveConfig } from '../src/lib/config';
import {
assertCommentInput,
assertPasteInput,
assertValidId,
getCipherSpecFromAdata,
getEnvelopeFlags,
HttpError,
isCipherSpec,
isLegacyCommentEnvelope,
isLegacyJsonApiCall,
parseLegacyPasteId,
} from '../src/lib/model';
import { sampleEnvelope } from './support';
describe('config helpers', () => {
it('uses built-in defaults when env is empty', () => {
const before = Date.now();
const config = resolveConfig({} as Env);
expect(config.appName).toBe('Xbin');
expect(config.appVersion).toBe('1.0.0');
expect(config.projectPageUrl).toBe('https://github.com/xixu-me/xbin');
expect(config.basePath).toBe('/');
expect(config.maxPasteBytes).toBe(10_000_000);
expect(config.enableLegacyApi).toBe(true);
expect(config.requireTurnstile).toBe(false);
expect(config.supportedExpirations).toEqual(['5min', '10min', '30min', '1hour', '3hour', '6hour', '12hour', '1day', '3day', '1week']);
expect(config.defaultExpiration).toBe('1hour');
expect(config.turnstileSiteKey).toBeNull();
expect(config.burnClaimTtlSeconds).toBe(120);
expect(config.uiLanguageLabel).toBe('English');
expect(config.uiLanguageCode).toBe('en');
expect(config.uiLanguageName).toBe('English');
expect(config.uiLanguageRtl).toBe(false);
expect(config.uiLanguages.find((option) => option.id === 'en')).toMatchObject({
label: 'English',
name: 'English',
});
expect(config.uiLanguages.find((option) => option.id === 'ar')).toMatchObject({
label: 'العربية',
name: 'Arabic',
rtl: true,
});
expect(config.uiThemeLabel).toBe('bootstrap5');
expect(config.uiThemes).toContain('bootstrap-dark');
expect(config.uiThemes).toContain('bootstrap-compact-page');
expect(expireAtFromKey('1hour')).toBeGreaterThan(before);
});
it('uses defaults and fallbacks when env values are missing or invalid', () => {
const config = resolveConfig({
XBIN_SUPPORTED_EXPIRATIONS: 'bogus',
XBIN_DEFAULT_EXPIRATION: '10min',
XBIN_MAX_PASTE_BYTES: 'NaN',
XBIN_ENABLE_LEGACY_API: '0',
XBIN_REQUIRE_TURNSTILE: '1',
TURNSTILE_SITE_KEY: 'site-key',
XBIN_BURN_CLAIM_TTL_SECONDS: 'not-a-number',
} as Env);
expect(config.appName).toBe('Xbin');
expect(config.appVersion).toBe('1.0.0');
expect(config.projectPageUrl).toBe('https://github.com/xixu-me/xbin');
expect(config.basePath).toBe('/');
expect(config.maxPasteBytes).toBe(10_000_000);
expect(config.enableLegacyApi).toBe(false);
expect(config.requireTurnstile).toBe(true);
expect(config.turnstileSiteKey).toBe('site-key');
expect(config.supportedExpirations).toEqual(['5min', '10min', '30min', '1hour', '3hour', '6hour', '12hour', '1day', '3day', '1week']);
expect(config.defaultExpiration).toBe('10min');
expect(config.burnClaimTtlSeconds).toBe(120);
expect(config.uiLanguageLabel).toBe('English');
expect(config.uiLanguageCode).toBe('en');
expect(config.uiLanguageName).toBe('English');
expect(config.uiLanguageRtl).toBe(false);
expect(config.uiLanguages).toHaveLength(37);
expect(config.uiThemeLabel).toBe('bootstrap5');
expect(config.uiThemes).toHaveLength(7);
});
it('parses explicit values and expiration timestamps', () => {
const config = resolveConfig({
XBIN_APP_NAME: 'Custom Bin',
XBIN_APP_VERSION: '9.9.9',
XBIN_PROJECT_PAGE_URL: 'https://example.com/project',
XBIN_BASE_PATH: '/nested/',
XBIN_MAX_PASTE_BYTES: '1234',
XBIN_DEFAULT_EXPIRATION: 'never',
XBIN_SUPPORTED_EXPIRATIONS: '5min, never, invalid',
XBIN_ENABLE_LEGACY_API: 'true',
XBIN_REQUIRE_TURNSTILE: '0',
XBIN_BURN_CLAIM_TTL_SECONDS: '45',
} as Env);
expect(config.appName).toBe('Custom Bin');
expect(config.appVersion).toBe('9.9.9');
expect(config.projectPageUrl).toBe('https://example.com/project');
expect(config.basePath).toBe('/nested/');
expect(config.maxPasteBytes).toBe(1234);
expect(config.enableLegacyApi).toBe(true);
expect(config.requireTurnstile).toBe(false);
expect(config.supportedExpirations).toEqual(['5min', 'never']);
expect(config.defaultExpiration).toBe('never');
expect(config.burnClaimTtlSeconds).toBe(45);
expect(config.uiLanguageLabel).toBe('English');
expect(config.uiLanguageCode).toBe('en');
expect(config.uiLanguageName).toBe('English');
expect(config.uiLanguageRtl).toBe(false);
expect(config.uiLanguages.find((option) => option.id === 'zh')).toMatchObject({
label: '中文',
name: 'Chinese',
});
expect(config.uiThemeLabel).toBe('bootstrap5');
expect(config.uiThemes[0]).toBe('bootstrap5');
expect(expireAtFromKey('5min', 1_000)).toBe(301_000);
expect(expireAtFromKey('never', 1_000)).toBeNull();
});
});
describe('model helpers', () => {
it('parses legacy paste identifiers and legacy API calls', () => {
expect(parseLegacyPasteId(new URL('https://example.com/?pasteid=abcdef1234567890'))).toBe('abcdef1234567890');
expect(parseLegacyPasteId(new URL('https://example.com/?abcdef1234567890'))).toBe('abcdef1234567890');
expect(parseLegacyPasteId(new URL('https://example.com/?foo=bar'))).toBeNull();
expect(
isLegacyJsonApiCall(
new Request('https://example.com', {
headers: { 'X-Requested-With': 'JSONHttpRequest' },
}),
),
).toBe(true);
expect(isLegacyJsonApiCall(new Request('https://example.com'))).toBe(false);
});
it('validates cipher metadata helpers and envelope flags', () => {
const spec = ['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'] as const;
expect(isCipherSpec(spec)).toBe(true);
expect(isCipherSpec(['iv', 'salt'])).toBe(false);
expect(getCipherSpecFromAdata(spec)).toEqual(spec);
expect(getCipherSpecFromAdata([null, 'markdown', 1, 1])).toBeNull();
expect(isLegacyCommentEnvelope({ adata: spec })).toBe(true);
expect(isLegacyCommentEnvelope({ adata: [null, 'plaintext', 0, 0] })).toBe(false);
expect(
getEnvelopeFlags({
...sampleEnvelope({
adata: [spec, '', 1, 1],
}),
}),
).toEqual({
formatter: 'plaintext',
discussionOpen: true,
burnAfterReading: true,
});
expect(getEnvelopeFlags(sampleEnvelope())).toEqual({
formatter: 'plaintext',
discussionOpen: false,
burnAfterReading: false,
});
expect(
getEnvelopeFlags({
...sampleEnvelope(),
adata: ['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'],
} as never),
).toEqual({
formatter: 'plaintext',
discussionOpen: false,
burnAfterReading: false,
});
});
it('rejects invalid identifiers and malformed paste payloads', () => {
expect(() => assertValidId('bad-id', 'paste identifier')).toThrowError(HttpError);
expect(() => assertPasteInput(null, 100, ['1day'])).toThrow('Invalid data.');
const oversized = sampleEnvelope({
ct: 'x'.repeat(20),
attachment: {
v: 2,
adata: [['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none']],
ct: 'x'.repeat(20),
},
});
expect(() => assertPasteInput(oversized, 10, ['1day'])).toThrow('Document exceeds the maximum encrypted size.');
const conflicting = sampleEnvelope({
adata: [['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'], 'plaintext', 1, 1],
});
expect(() => assertPasteInput(conflicting, 100, ['1day'])).toThrow('Burn-after-reading pastes cannot have discussions enabled.');
expect(() => assertPasteInput(sampleEnvelope({ adata: [null, 'plaintext', 0, 0] }), 100, ['1day'])).toThrow('Invalid data.');
expect(() =>
assertPasteInput(
sampleEnvelope({
ct: 'short',
attachment_name: {
v: 2,
adata: [['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none']],
ct: 'x'.repeat(20),
},
}),
10,
['1day'],
),
).toThrow('Document exceeds the maximum encrypted size.');
});
it('normalizes paste and comment inputs', () => {
const paste = assertPasteInput(
sampleEnvelope({
meta: { expire: 'invalid-expire' },
turnstileToken: 'token-123',
}),
10_000,
['1day', '1week'],
);
expect(paste.expireKey).toBe('1day');
expect(paste.turnstileToken).toBe('token-123');
const comment = assertCommentInput(
{
v: 2,
adata: ['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'],
ct: 'comment-ciphertext',
parentid: 'abcdef1234567890',
turnstileToken: 'comment-token',
},
10_000,
'abcdef1234567890',
);
expect(comment.parentId).toBe('abcdef1234567890');
expect(comment.turnstileToken).toBe('comment-token');
const fallbackParent = assertCommentInput(
{
v: 2,
adata: ['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'],
ct: 'comment-ciphertext',
},
10_000,
'abcdef1234567890',
);
expect(fallbackParent.parentId).toBe('abcdef1234567890');
expect(() =>
assertCommentInput(
{
v: 2,
adata: ['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'],
ct: 'comment-ciphertext',
pasteid: 'ffffffffffffffff',
},
10_000,
'abcdef1234567890',
),
).toThrow('Comment paste identifier does not match the route.');
expect(() =>
assertCommentInput(
{
v: 2,
adata: ['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'],
ct: 'x'.repeat(100),
},
10,
'abcdef1234567890',
),
).toThrow('Comment exceeds the maximum encrypted size.');
expect(() =>
assertCommentInput(
{
v: 2,
adata: ['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'],
ct: 'comment-ciphertext',
parentId: 'not-an-id',
},
10_000,
'abcdef1234567890',
),
).toThrow('Invalid parent identifier.');
expect(() =>
assertCommentInput(
{
v: 1,
adata: ['iv', 'salt', 100000, 256, 128, 'aes', 'gcm', 'none'],
ct: 'comment-ciphertext',
},
10_000,
'abcdef1234567890',
),
).toThrow('Invalid data.');
expect(() =>
assertCommentInput(
{
v: 2,
adata: [null, 'plaintext', 0, 0],
ct: 'comment-ciphertext',
},
10_000,
'abcdef1234567890',
),
).toThrow('Invalid data.');
});
});