Lightweight replacement for the upstream La Suite Numérique drive (Django/Celery/Next.js) built as a single Deno binary. Server (Deno + Hono): - S3 file operations via AWS SigV4 (no SDK) with pre-signed URLs - WOPI host for Collabora Online (CheckFileInfo, GetFile, PutFile, locks) - Ory Kratos session auth + CSRF protection - Ory Keto permission model (OPL namespaces, not yet wired to routes) - PostgreSQL metadata with recursive folder sizes - S3 backfill API for registering files uploaded outside the UI - OpenTelemetry tracing + metrics (opt-in via OTEL_ENABLED) Frontend (React 19 + Cunningham v4 + react-aria): - File browser with GridList, keyboard nav, multi-select - Collabora editor iframe (full-screen, form POST, postMessage) - Profile menu, waffle menu, drag-drop upload, asset type badges - La Suite integration service theming (runtime CSS) Testing (549 tests): - 235 server unit tests (Deno) — 90%+ coverage - 278 UI unit tests (Vitest) — 90%+ coverage - 11 E2E tests (Playwright) - 12 integration service tests (Playwright) - 13 WOPI integration tests (Playwright + Docker Compose + Collabora) MIT licensed.
292 lines
9.3 KiB
TypeScript
292 lines
9.3 KiB
TypeScript
/**
|
|
* Tests for WOPI lock service using in-memory store.
|
|
*/
|
|
|
|
import {
|
|
assertEquals,
|
|
assertNotEquals,
|
|
} from "https://deno.land/std@0.224.0/assert/mod.ts";
|
|
import {
|
|
InMemoryLockStore,
|
|
setLockStore,
|
|
acquireLock,
|
|
getLock,
|
|
refreshLock,
|
|
releaseLock,
|
|
unlockAndRelock,
|
|
} from "../../server/wopi/lock.ts";
|
|
|
|
// Use in-memory store for all tests
|
|
function setup(): InMemoryLockStore {
|
|
const store = new InMemoryLockStore();
|
|
setLockStore(store);
|
|
return store;
|
|
}
|
|
|
|
Deno.test("acquireLock succeeds on unlocked file", async () => {
|
|
setup();
|
|
const result = await acquireLock("file-1", "lock-aaa");
|
|
assertEquals(result.success, true);
|
|
assertEquals(result.existingLockId, undefined);
|
|
});
|
|
|
|
Deno.test("acquireLock fails when different lock exists", async () => {
|
|
setup();
|
|
await acquireLock("file-1", "lock-aaa");
|
|
const result = await acquireLock("file-1", "lock-bbb");
|
|
assertEquals(result.success, false);
|
|
assertEquals(result.existingLockId, "lock-aaa");
|
|
});
|
|
|
|
Deno.test("acquireLock succeeds when same lock exists (refresh)", async () => {
|
|
setup();
|
|
await acquireLock("file-1", "lock-aaa");
|
|
const result = await acquireLock("file-1", "lock-aaa");
|
|
assertEquals(result.success, true);
|
|
});
|
|
|
|
Deno.test("getLock returns null for unlocked file", async () => {
|
|
setup();
|
|
const lock = await getLock("file-nonexistent");
|
|
assertEquals(lock, null);
|
|
});
|
|
|
|
Deno.test("getLock returns lock id for locked file", async () => {
|
|
setup();
|
|
await acquireLock("file-1", "lock-xyz");
|
|
const lock = await getLock("file-1");
|
|
assertEquals(lock, "lock-xyz");
|
|
});
|
|
|
|
Deno.test("refreshLock succeeds with matching lock", async () => {
|
|
setup();
|
|
await acquireLock("file-1", "lock-aaa");
|
|
const result = await refreshLock("file-1", "lock-aaa");
|
|
assertEquals(result.success, true);
|
|
});
|
|
|
|
Deno.test("refreshLock fails with mismatched lock", async () => {
|
|
setup();
|
|
await acquireLock("file-1", "lock-aaa");
|
|
const result = await refreshLock("file-1", "lock-bbb");
|
|
assertEquals(result.success, false);
|
|
assertEquals(result.existingLockId, "lock-aaa");
|
|
});
|
|
|
|
Deno.test("refreshLock fails on unlocked file", async () => {
|
|
setup();
|
|
const result = await refreshLock("file-1", "lock-aaa");
|
|
assertEquals(result.success, false);
|
|
});
|
|
|
|
Deno.test("releaseLock succeeds with matching lock", async () => {
|
|
setup();
|
|
await acquireLock("file-1", "lock-aaa");
|
|
const result = await releaseLock("file-1", "lock-aaa");
|
|
assertEquals(result.success, true);
|
|
|
|
// Verify lock is gone
|
|
const lock = await getLock("file-1");
|
|
assertEquals(lock, null);
|
|
});
|
|
|
|
Deno.test("releaseLock fails with mismatched lock", async () => {
|
|
setup();
|
|
await acquireLock("file-1", "lock-aaa");
|
|
const result = await releaseLock("file-1", "lock-bbb");
|
|
assertEquals(result.success, false);
|
|
assertEquals(result.existingLockId, "lock-aaa");
|
|
|
|
// Lock should still exist
|
|
const lock = await getLock("file-1");
|
|
assertEquals(lock, "lock-aaa");
|
|
});
|
|
|
|
Deno.test("releaseLock succeeds on already unlocked file", async () => {
|
|
setup();
|
|
const result = await releaseLock("file-1", "lock-aaa");
|
|
assertEquals(result.success, true);
|
|
});
|
|
|
|
Deno.test("unlockAndRelock succeeds with matching old lock", async () => {
|
|
setup();
|
|
await acquireLock("file-1", "lock-old");
|
|
const result = await unlockAndRelock("file-1", "lock-old", "lock-new");
|
|
assertEquals(result.success, true);
|
|
|
|
// New lock should be set
|
|
const lock = await getLock("file-1");
|
|
assertEquals(lock, "lock-new");
|
|
});
|
|
|
|
Deno.test("unlockAndRelock fails with mismatched old lock", async () => {
|
|
setup();
|
|
await acquireLock("file-1", "lock-aaa");
|
|
const result = await unlockAndRelock("file-1", "lock-wrong", "lock-new");
|
|
assertEquals(result.success, false);
|
|
assertEquals(result.existingLockId, "lock-aaa");
|
|
|
|
// Original lock should remain
|
|
const lock = await getLock("file-1");
|
|
assertEquals(lock, "lock-aaa");
|
|
});
|
|
|
|
Deno.test("unlockAndRelock fails when no lock exists", async () => {
|
|
setup();
|
|
const result = await unlockAndRelock("file-1", "lock-old", "lock-new");
|
|
assertEquals(result.success, false);
|
|
assertEquals(result.existingLockId, undefined);
|
|
});
|
|
|
|
Deno.test("different files have independent locks", async () => {
|
|
setup();
|
|
await acquireLock("file-1", "lock-1");
|
|
await acquireLock("file-2", "lock-2");
|
|
|
|
assertEquals(await getLock("file-1"), "lock-1");
|
|
assertEquals(await getLock("file-2"), "lock-2");
|
|
|
|
// Releasing one doesn't affect the other
|
|
await releaseLock("file-1", "lock-1");
|
|
assertEquals(await getLock("file-1"), null);
|
|
assertEquals(await getLock("file-2"), "lock-2");
|
|
});
|
|
|
|
Deno.test("full lock lifecycle: acquire -> refresh -> release", async () => {
|
|
setup();
|
|
|
|
// Acquire
|
|
const a = await acquireLock("file-1", "lock-abc");
|
|
assertEquals(a.success, true);
|
|
|
|
// Refresh
|
|
const r = await refreshLock("file-1", "lock-abc");
|
|
assertEquals(r.success, true);
|
|
|
|
// Still locked
|
|
assertNotEquals(await getLock("file-1"), null);
|
|
|
|
// Release
|
|
const rel = await releaseLock("file-1", "lock-abc");
|
|
assertEquals(rel.success, true);
|
|
|
|
// Gone
|
|
assertEquals(await getLock("file-1"), null);
|
|
});
|
|
|
|
// ── InMemoryLockStore direct tests ──────────────────────────────────────────
|
|
|
|
Deno.test("InMemoryLockStore — get returns null for nonexistent key", async () => {
|
|
const store = new InMemoryLockStore();
|
|
assertEquals(await store.get("nonexistent"), null);
|
|
});
|
|
|
|
Deno.test("InMemoryLockStore — setNX sets value and returns true", async () => {
|
|
const store = new InMemoryLockStore();
|
|
const result = await store.setNX("key1", "value1", 60);
|
|
assertEquals(result, true);
|
|
assertEquals(await store.get("key1"), "value1");
|
|
});
|
|
|
|
Deno.test("InMemoryLockStore — setNX returns false if key exists", async () => {
|
|
const store = new InMemoryLockStore();
|
|
await store.setNX("key1", "value1", 60);
|
|
const result = await store.setNX("key1", "value2", 60);
|
|
assertEquals(result, false);
|
|
assertEquals(await store.get("key1"), "value1");
|
|
});
|
|
|
|
Deno.test("InMemoryLockStore — set overwrites unconditionally", async () => {
|
|
const store = new InMemoryLockStore();
|
|
await store.setNX("key1", "value1", 60);
|
|
await store.set("key1", "value2", 60);
|
|
assertEquals(await store.get("key1"), "value2");
|
|
});
|
|
|
|
Deno.test("InMemoryLockStore — del removes key", async () => {
|
|
const store = new InMemoryLockStore();
|
|
await store.setNX("key1", "value1", 60);
|
|
await store.del("key1");
|
|
assertEquals(await store.get("key1"), null);
|
|
});
|
|
|
|
Deno.test("InMemoryLockStore — del on nonexistent key is no-op", async () => {
|
|
const store = new InMemoryLockStore();
|
|
await store.del("nonexistent");
|
|
// Should not throw
|
|
});
|
|
|
|
Deno.test("InMemoryLockStore — expire returns false for nonexistent key", async () => {
|
|
const store = new InMemoryLockStore();
|
|
const result = await store.expire("nonexistent", 60);
|
|
assertEquals(result, false);
|
|
});
|
|
|
|
Deno.test("InMemoryLockStore — expire returns true for existing key", async () => {
|
|
const store = new InMemoryLockStore();
|
|
await store.setNX("key1", "value1", 60);
|
|
const result = await store.expire("key1", 120);
|
|
assertEquals(result, true);
|
|
assertEquals(await store.get("key1"), "value1");
|
|
});
|
|
|
|
Deno.test("InMemoryLockStore — expired key returns null on get", async () => {
|
|
const store = new InMemoryLockStore();
|
|
// Set with 0 TTL so it expires immediately
|
|
await store.set("key1", "value1", 0);
|
|
// Wait briefly to ensure expiry (0 seconds TTL)
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEquals(await store.get("key1"), null);
|
|
});
|
|
|
|
Deno.test("InMemoryLockStore — expired key allows setNX", async () => {
|
|
const store = new InMemoryLockStore();
|
|
await store.set("key1", "value1", 0);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const result = await store.setNX("key1", "value2", 60);
|
|
assertEquals(result, true);
|
|
assertEquals(await store.get("key1"), "value2");
|
|
});
|
|
|
|
Deno.test("InMemoryLockStore — expire on expired key returns false", async () => {
|
|
const store = new InMemoryLockStore();
|
|
await store.set("key1", "value1", 0);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const result = await store.expire("key1", 60);
|
|
assertEquals(result, false);
|
|
});
|
|
|
|
// ── Lock TTL-related tests ──────────────────────────────────────────────────
|
|
|
|
Deno.test("acquireLock then getLock after TTL expiry returns null", async () => {
|
|
const store = new InMemoryLockStore();
|
|
setLockStore(store);
|
|
|
|
// Directly set a lock with very short TTL via the store
|
|
await store.set("wopi:lock:file-expiry", "lock-ttl", 0);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
const lock = await store.get("wopi:lock:file-expiry");
|
|
assertEquals(lock, null);
|
|
});
|
|
|
|
Deno.test("concurrent lock attempts — second attempt fails", async () => {
|
|
setup();
|
|
const [r1, r2] = await Promise.all([
|
|
acquireLock("file-concurrent", "lock-a"),
|
|
acquireLock("file-concurrent", "lock-b"),
|
|
]);
|
|
|
|
// One should succeed, one should fail (or both succeed with same lock)
|
|
const successes = [r1, r2].filter((r) => r.success);
|
|
const failures = [r1, r2].filter((r) => !r.success);
|
|
|
|
// At least one should succeed
|
|
assertEquals(successes.length >= 1, true);
|
|
|
|
if (failures.length > 0) {
|
|
// If one failed, it should report the existing lock
|
|
assertNotEquals(failures[0].existingLockId, undefined);
|
|
}
|
|
});
|