Decrypted clip blob: URLs lived in an unbounded module Map for the page lifetime and survived logout. Add clearSoundboardClipCache() (called from both logout paths next to clearPlaintextCaches) and a 64-entry LRU that revokes on evict. Unit-tested. Fixes #57 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
87 lines
3.2 KiB
TypeScript
87 lines
3.2 KiB
TypeScript
import { test, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import type { MatrixClient } from 'matrix-js-sdk';
|
|
import { resolveClipObjectUrl, clearSoundboardClipCache } from './soundboardClips';
|
|
|
|
// [Gitea #57] resolveClipObjectUrl/clearSoundboardClipCache talk to
|
|
// URL.createObjectURL/revokeObjectURL and `fetch` — neither exists in this
|
|
// Node test environment, so each case stubs minimal fakes and restores them.
|
|
|
|
const originalCreateObjectURL = URL.createObjectURL;
|
|
const originalRevokeObjectURL = URL.revokeObjectURL;
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
let nextUrlId = 0;
|
|
let revoked: string[] = [];
|
|
|
|
const fakeMx = {
|
|
getHomeserverUrl: () => 'https://example.org',
|
|
} as unknown as MatrixClient;
|
|
|
|
beforeEach(() => {
|
|
nextUrlId = 0;
|
|
revoked = [];
|
|
(URL as unknown as { createObjectURL: (b: Blob) => string }).createObjectURL = () =>
|
|
`blob:fake-${nextUrlId++}`;
|
|
(URL as unknown as { revokeObjectURL: (u: string) => void }).revokeObjectURL = (url: string) => {
|
|
revoked.push(url);
|
|
};
|
|
globalThis.fetch = (async () =>
|
|
({
|
|
ok: true,
|
|
blob: async () => new Blob(['clip']),
|
|
}) as Response) as typeof fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearSoundboardClipCache();
|
|
URL.createObjectURL = originalCreateObjectURL;
|
|
URL.revokeObjectURL = originalRevokeObjectURL;
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
test('caches the resolved object URL per mxc and does not re-download', async () => {
|
|
let downloads = 0;
|
|
globalThis.fetch = (async () => {
|
|
downloads += 1;
|
|
return { ok: true, blob: async () => new Blob(['clip']) } as Response;
|
|
}) as typeof fetch;
|
|
|
|
const first = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip1');
|
|
const second = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip1');
|
|
|
|
assert.equal(first, second);
|
|
assert.equal(downloads, 1);
|
|
});
|
|
|
|
test('clearSoundboardClipCache revokes every cached URL and empties the map', async () => {
|
|
const urlA = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/a');
|
|
const urlB = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/b');
|
|
|
|
clearSoundboardClipCache();
|
|
|
|
assert.deepEqual(new Set(revoked), new Set([urlA, urlB]));
|
|
|
|
// Cache is empty, so resolving the same mxc again re-downloads (new blob URL).
|
|
const urlAAgain = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/a');
|
|
assert.notEqual(urlAAgain, urlA);
|
|
});
|
|
|
|
test('evicts the least-recently-used entry once the cache exceeds its cap', async () => {
|
|
// Cap is 64; fill it, then add one more and confirm exactly one eviction.
|
|
for (let i = 0; i < 64; i += 1) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await resolveClipObjectUrl(fakeMx, `mxc://example.org/clip-${i}`);
|
|
}
|
|
assert.equal(revoked.length, 0);
|
|
|
|
const firstUrl = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip-0');
|
|
// Re-resolving clip-0 above marked it as recently used, so the least-recently
|
|
// used entry is now clip-1 — adding a new clip should evict that one.
|
|
await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip-new');
|
|
|
|
assert.equal(revoked.length, 1);
|
|
// clip-0 must not have been evicted since it was refreshed just before.
|
|
assert.equal(revoked.includes(firstUrl), false);
|
|
});
|