Initial commit — Drive, an S3 file browser with WOPI editing
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.
This commit is contained in:
291
tests/server/wopi_lock_test.ts
Normal file
291
tests/server/wopi_lock_test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user