mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 01:56:27 +08:00
c9bffc13b5
fix #13529 #### PR Dependency Tree * **PR #14481** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Mobile blob caching with file-backed storage for faster loads and reduced network usage * Blob decoding with lazy refresh on token-read failures for improved reliability * Full-text search/indexing exposed to mobile apps * Document sync APIs and peer clock management for robust cross-device sync * **Tests** * Added unit tests covering payload decoding, cache safety, and concurrency * **Dependencies** * Added an LRU cache dependency and a new mobile-shared package for shared mobile logic <!-- end of auto-generated comment: release notes by coderabbit.ai -->
146 lines
4.7 KiB
TypeScript
146 lines
4.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { mockBase64ToUint8Array, mockConvertFileSrc } = vi.hoisted(() => ({
|
|
mockBase64ToUint8Array: vi.fn((data: string) =>
|
|
Uint8Array.from(data.split('').map(char => char.charCodeAt(0)))
|
|
),
|
|
mockConvertFileSrc: vi.fn((path: string) => `capacitor://localhost${path}`),
|
|
}));
|
|
|
|
vi.mock('@affine/core/modules/workspace-engine', () => ({
|
|
base64ToUint8Array: mockBase64ToUint8Array,
|
|
}));
|
|
|
|
vi.mock('@capacitor/core', () => ({
|
|
Capacitor: {
|
|
convertFileSrc: mockConvertFileSrc,
|
|
},
|
|
}));
|
|
|
|
import { decodePayload, MOBILE_BLOB_FILE_PREFIX } from './payload';
|
|
|
|
describe('decodePayload', () => {
|
|
const fetchMock = vi.fn<typeof fetch>();
|
|
|
|
beforeEach(() => {
|
|
fetchMock.mockReset();
|
|
mockBase64ToUint8Array.mockClear();
|
|
mockConvertFileSrc.mockClear();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('decodes inline base64 payloads without file IO', async () => {
|
|
const decoded = await decodePayload('ZGF0YQ==', MOBILE_BLOB_FILE_PREFIX);
|
|
expect(decoded).toEqual(Uint8Array.from([90, 71, 70, 48, 89, 81, 61, 61]));
|
|
expect(mockBase64ToUint8Array).toHaveBeenCalledWith('ZGF0YQ==');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reads valid cache file tokens', async () => {
|
|
const expected = Uint8Array.from([1, 2, 3, 4]);
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
arrayBuffer: async () => expected.buffer,
|
|
} as Response);
|
|
|
|
const path =
|
|
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
|
const decoded = await decodePayload(
|
|
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
|
MOBILE_BLOB_FILE_PREFIX
|
|
);
|
|
|
|
expect(decoded).toEqual(expected);
|
|
expect(mockConvertFileSrc).toHaveBeenCalledWith(`file://${path}`);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('reads valid android cache file tokens', async () => {
|
|
const expected = Uint8Array.from([4, 3, 2, 1]);
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
arrayBuffer: async () => expected.buffer,
|
|
} as Response);
|
|
|
|
const path =
|
|
'/data/user/0/com.affine.app/cache/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
|
const decoded = await decodePayload(
|
|
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
|
MOBILE_BLOB_FILE_PREFIX
|
|
);
|
|
|
|
expect(decoded).toEqual(expected);
|
|
expect(mockConvertFileSrc).toHaveBeenCalledWith(`file://${path}`);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rejects suffix-only paths outside expected cache shape', async () => {
|
|
const path =
|
|
'/attacker/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
|
await expect(
|
|
decodePayload(
|
|
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
|
MOBILE_BLOB_FILE_PREFIX
|
|
)
|
|
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects random cache roots', async () => {
|
|
const path =
|
|
'/random/cache/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
|
await expect(
|
|
decodePayload(
|
|
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
|
MOBILE_BLOB_FILE_PREFIX
|
|
)
|
|
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects encoded traversal segments', async () => {
|
|
const path =
|
|
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/%2E%2E/fedcba9876543210.blob';
|
|
await expect(
|
|
decodePayload(
|
|
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
|
MOBILE_BLOB_FILE_PREFIX
|
|
)
|
|
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retries once with refreshed payload when token read fails', async () => {
|
|
const path =
|
|
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
|
const payload = `${MOBILE_BLOB_FILE_PREFIX}${path}`;
|
|
const expected = Uint8Array.from([9, 8, 7]);
|
|
|
|
fetchMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
} as Response)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
arrayBuffer: async () => expected.buffer,
|
|
} as Response);
|
|
|
|
const reloadedPayload = vi.fn(async () => payload);
|
|
const decoded = await decodePayload(payload, MOBILE_BLOB_FILE_PREFIX, {
|
|
onTokenReadFailure: reloadedPayload,
|
|
});
|
|
|
|
expect(decoded).toEqual(expected);
|
|
expect(reloadedPayload).toHaveBeenCalledTimes(1);
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|