forked from akai/readest
A wrong Server URL can land on the host's static web UI, which answers 200 OK with an HTML page. connect() treated any 2xx from /users/auth (or /users/create) as a successful login, so the user was silently "connected" to an endpoint that can never sync: pulls report 0% and pushes fail with no error surfaced. This is the root of the symptom in #4692 (KOReader sync to a Grimmory/Booklore server failing on Android). Validate that the auth/registration response is an actual KOReader Sync JSON object (a real server replies e.g. {"authorized":"OK"}; an HTML page fails JSON parsing) and otherwise return a clear "Not a KOReader Sync server. Check the Server URL." message. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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> = {}): 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<typeof vi.fn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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.');
|
||||
|
||||
Reference in New Issue
Block a user