diff --git a/apps/readest-app/src/__tests__/services/sync/KOSyncClient.test.ts b/apps/readest-app/src/__tests__/services/sync/KOSyncClient.test.ts new file mode 100644 index 00000000..d6567045 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/KOSyncClient.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { KOSyncClient } from '@/services/sync/KOSyncClient'; +import { KOSyncSettings } from '@/types/settings'; + +// The LAN-server branch of KOSyncClient.request uses window.fetch (mocked +// per-test); the Tauri HTTP plugin is never invoked here, so stub the import +// to keep the unit environment free of Tauri internals. +vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); + +const makeConfig = (overrides: Partial = {}): KOSyncSettings => ({ + enabled: true, + // A LAN address makes request() take the direct window.fetch path. + serverUrl: 'http://192.168.1.50', + username: 'alice', + userkey: '', + password: '', + deviceId: 'device-1', + deviceName: 'Readest', + checksumMethod: 'binary', + strategy: 'prompt', + ...overrides, +}); + +type FetchMock = ReturnType; + +const setFetch = (impl: (...args: unknown[]) => unknown): FetchMock => { + const mock = vi.fn(impl) as FetchMock; + vi.stubGlobal('fetch', mock); + window.fetch = mock as unknown as typeof window.fetch; + return mock; +}; + +// Minimal Response-like object covering the fields KOSyncClient reads. +const htmlPage = (status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => { + throw new SyntaxError('Unexpected token < in JSON'); + }, +}); + +const jsonResponse = (status: number, body: unknown) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}); + +describe('KOSyncClient.connect – server validation', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fails when /users/auth returns 200 with a non-JSON (web UI) page', async () => { + // A wrong Server URL that lands on the server's static web UI returns the + // HTML index page with 200 OK. That must NOT be treated as a successful + // login (it isn't a KOReader sync endpoint). + setFetch(() => htmlPage(200)); + + const client = new KOSyncClient(makeConfig()); + const result = await client.connect('alice', 'secret'); + + expect(result.success).toBe(false); + }); + + it('succeeds when /users/auth returns a valid KOReader auth JSON', async () => { + setFetch(() => jsonResponse(200, { authorized: 'OK' })); + + const client = new KOSyncClient(makeConfig()); + const result = await client.connect('alice', 'secret'); + + expect(result.success).toBe(true); + }); + + it('fails when registration (/users/create) returns 200 with a non-JSON page', async () => { + // /users/auth → 401 routes connect() into the create path; a web UI that + // returns 200 HTML there must not be reported as a successful registration. + const mock = setFetch((url: unknown) => { + if (String(url).includes('/users/create')) return htmlPage(200); + return htmlPage(401); // auth fails -> triggers create + }); + + const client = new KOSyncClient(makeConfig()); + const result = await client.connect('alice', 'secret'); + + expect(result.success).toBe(false); + expect(mock).toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/services/sync/KOSyncClient.ts b/apps/readest-app/src/services/sync/KOSyncClient.ts index e38ea055..636800bd 100644 --- a/apps/readest-app/src/services/sync/KOSyncClient.ts +++ b/apps/readest-app/src/services/sync/KOSyncClient.ts @@ -129,6 +129,13 @@ export class KOSyncClient { }); if (authResponse.ok) { + // A wrong Server URL can land on the host's web UI, which answers 200 + // with an HTML page. Only treat the response as a successful login when + // it's an actual KOReader Sync JSON response, otherwise the user is + // silently "connected" to an endpoint that can never sync. + if (!(await this.isKoSyncJsonResponse(authResponse))) { + return { success: false, message: 'Not a KOReader Sync server. Check the Server URL.' }; + } return { success: true, message: 'Login successful.' }; } @@ -140,6 +147,9 @@ export class KOSyncClient { }); if (registerResponse.ok) { + if (!(await this.isKoSyncJsonResponse(registerResponse))) { + return { success: false, message: 'Not a KOReader Sync server. Check the Server URL.' }; + } return { success: true, message: 'Registration successful.' }; } @@ -230,6 +240,17 @@ export class KOSyncClient { } } + /** + * A genuine KOReader Sync server replies with a JSON object (e.g. + * `{ "authorized": "OK" }`). A misconfigured Server URL that hits a static + * web UI returns an HTML page instead, which fails JSON parsing — use that to + * tell the two apart. + */ + private async isKoSyncJsonResponse(response: Response): Promise { + const data = await response.json().catch(() => null); + return typeof data === 'object' && data !== null; + } + getDocumentDigest(book: Book): string { if (this.config.checksumMethod === 'filename') { console.warn('This is not possible anymore, using md5 instead.');