Files
readest/apps/readest-app/src/__tests__/libs/replicaSchemas.test.ts
T
Huang Xin 6bfeb295d2 feat(sync): add opds_catalog replica kind (plaintext fields) (#4087)
Wires OPDS catalogs through replica sync as a metadata-only kind.
Plaintext fields only in this PR — encrypted credentials (username,
password) ship in the follow-up alongside the SyncPassphrasePanel UI
and Tauri keychain backend.

- Migration 009 extends the kind allowlist with 'opds_catalog'.
- replicaSchemas adds opdsCatalogFieldsSchema (name, url, description,
  icon, customHeaders, autoDownload, disabled, addedAt) with a 50-row
  per-user cap.
- New opdsCatalogAdapter is metadata-only (no `binary` capability).
  Stable cross-device id from md5("opds:" + url.lower()) so two
  devices that import the same URL converge to one row instead of
  duplicating.
- New customOPDSStore (zustand) hydrates from SystemSettings,
  publishes upserts/deletes through the replica pipeline, preserves
  local-only username/password when overlaying remote updates, and
  strips tombstones at the persistence boundary so existing
  useSettingsStore readers (useOPDSSubscriptions, pseStream,
  app/opds/page.tsx) need no migration.
- replicaPullAndApply branches on adapter.binary so metadata-only
  kinds skip the bundleDir requirement and the manifest/binary path.
- CatalogManager rewires Add / Edit / Remove / Toggle / Add-popular
  through the new store.

Plan update bundled in: tenet 8 (scalar settings sync via a bundled
row; collections sync per-record), per-kind allowlist now includes a
`settings` singleton that will collapse PRs 5 + 6+ into one bundled
adapter, and PR 4 is split into 4a (already merged) / 4b (this) / 4c
(encrypted credentials + UX).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 05:33:18 +02:00

179 lines
5.7 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import {
KIND_ALLOWLIST,
isAllowedKind,
validateFilename,
validateRow,
MAX_JSON_BYTES,
MAX_FIELD_COUNT,
} from '@/libs/replicaSchemas';
import type { ReplicaRow, Hlc } from '@/types/replica';
const HLC_A = '0000000000064-00000000-dev-a' as Hlc;
const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
user_id: 'u1',
kind: 'dictionary',
replica_id: 'r1',
fields_jsonb: {
name: { v: 'Webster', t: HLC_A, s: 'dev-a' },
enabled: { v: true, t: HLC_A, s: 'dev-a' },
},
manifest_jsonb: null,
deleted_at_ts: null,
reincarnation: null,
updated_at_ts: HLC_A,
schema_version: 1,
...overrides,
});
describe('isAllowedKind', () => {
test('current allowlist contains dictionary + font + texture + opds_catalog', () => {
expect(isAllowedKind('dictionary')).toBe(true);
expect(isAllowedKind('font')).toBe(true);
expect(isAllowedKind('texture')).toBe(true);
expect(isAllowedKind('opds_catalog')).toBe(true);
});
test('rejects arbitrary strings', () => {
expect(isAllowedKind('arbitrary')).toBe(false);
expect(isAllowedKind('')).toBe(false);
expect(isAllowedKind('../etc/passwd')).toBe(false);
});
test('KIND_ALLOWLIST keys match isAllowedKind', () => {
for (const k of Object.keys(KIND_ALLOWLIST)) {
expect(isAllowedKind(k)).toBe(true);
}
});
});
describe('validateFilename', () => {
test('accepts plain filenames', () => {
expect(validateFilename('webster.mdx').ok).toBe(true);
expect(validateFilename('webster.mdd').ok).toBe(true);
expect(validateFilename('manifest.json').ok).toBe(true);
expect(validateFilename('foo-bar_baz.css').ok).toBe(true);
});
test('rejects path traversal', () => {
expect(validateFilename('../etc/passwd').ok).toBe(false);
expect(validateFilename('..').ok).toBe(false);
expect(validateFilename('foo/../bar').ok).toBe(false);
});
test('rejects path separators', () => {
expect(validateFilename('foo/bar').ok).toBe(false);
expect(validateFilename('foo\\bar').ok).toBe(false);
});
test('rejects empty / too-long names', () => {
expect(validateFilename('').ok).toBe(false);
expect(validateFilename('a'.repeat(256)).ok).toBe(false);
});
test('accepts up to 255 chars', () => {
expect(validateFilename('a'.repeat(255)).ok).toBe(true);
});
test('rejects null bytes', () => {
expect(validateFilename('foo\u0000bar').ok).toBe(false);
});
test('rejects control characters', () => {
expect(validateFilename('foo\u0001bar').ok).toBe(false);
expect(validateFilename('foo\u001fbar').ok).toBe(false);
expect(validateFilename('foo\u007fbar').ok).toBe(false);
});
test('accepts unicode (utf-8 dictionary names common in CJK locales)', () => {
expect(validateFilename('韦氏高阶英汉双解词典.mdx').ok).toBe(true);
});
});
describe('validateRow', () => {
test('accepts a valid dictionary row', () => {
const result = validateRow(baseRow());
expect(result.ok).toBe(true);
});
test('rejects unknown kind', () => {
const result = validateRow(baseRow({ kind: 'arbitrary' }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('UNKNOWN_KIND');
});
test('rejects fields_jsonb beyond MAX_JSON_BYTES (64 KiB)', () => {
const huge = { ...baseRow().fields_jsonb };
huge['blob'] = { v: 'x'.repeat(MAX_JSON_BYTES), t: HLC_A, s: 'dev-a' };
const result = validateRow(baseRow({ fields_jsonb: huge }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('VALIDATION');
});
test('rejects fields_jsonb with too many fields (> 64)', () => {
const fields: Record<string, unknown> = {};
for (let i = 0; i < MAX_FIELD_COUNT + 1; i++) {
fields[`field${i}`] = { v: i, t: HLC_A, s: 'dev-a' };
}
const result = validateRow(baseRow({ fields_jsonb: fields as never }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('VALIDATION');
});
test('rejects schemaVersion below minSupported', () => {
const result = validateRow(baseRow({ schema_version: 0 }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('SCHEMA_TOO_NEW');
});
test('rejects schemaVersion above maxKnown', () => {
const result = validateRow(baseRow({ schema_version: 999 }));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('SCHEMA_TOO_NEW');
});
test('rejects manifest with invalid filename', () => {
const result = validateRow(
baseRow({
manifest_jsonb: {
schemaVersion: 1,
files: [{ filename: '../etc/passwd', byteSize: 1, partialMd5: 'abc' }],
},
}),
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('VALIDATION');
});
test('accepts manifest with valid filenames', () => {
const result = validateRow(
baseRow({
manifest_jsonb: {
schemaVersion: 1,
files: [
{ filename: 'webster.mdx', byteSize: 1024, partialMd5: 'abcdef' },
{ filename: 'webster.mdd', byteSize: 2048, partialMd5: 'abcdef' },
],
},
}),
);
expect(result.ok).toBe(true);
});
test('preserves unknown fields (forwards-compat)', () => {
const row = baseRow();
row.fields_jsonb['future_field'] = { v: 'unknown', t: HLC_A, s: 'dev-a' };
const result = validateRow(row);
expect(result.ok).toBe(true);
});
test('rejects fields with malformed envelope (missing v/t/s)', () => {
const row = baseRow();
row.fields_jsonb['broken'] = { v: 'x' } as never;
const result = validateRow(row);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.code).toBe('VALIDATION');
});
});