CI / Build & Quality Checks (pull_request) Successful in 2m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m33s
m.room.retention can be set to anything by anyone with state power. A fat-fingered `max_lifetime: 1000` blanked the timeline for every Lotus user and, for members with "Enforce Message Retention" on, the sweeper redacted all their messages within a minute. The RETENTION_MIN_MS floor existed but was never applied. - effectiveRetentionMs(): anything under 10 min counts as off; used by getRoomRetentionMs (sweeper) and the timeline's hiding. - The sweeper never deletes under a policy less than 24 h old, so a mistake or a malicious change can be reverted before anything is lost. (Local hiding of expired messages still applies at once.) Verified headless (the issue's repro, enforcement on): after max_lifetime 1000, all 4 messages stay visible and none are redacted after 45 s (before: timeline blank, all 4 redacted). Unit tests for the floor and the grace window. Fixes #298 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
69 lines
2.6 KiB
TypeScript
69 lines
2.6 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
effectiveRetentionMs,
|
|
isExpired,
|
|
RETENTION_DELETE_GRACE_MS,
|
|
RETENTION_MIN_MS,
|
|
RETENTION_PRESETS,
|
|
retentionDeletesAllowed,
|
|
} from './retention';
|
|
|
|
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
|
// retention window. It must be strict (> window, not >=) and a disabled policy
|
|
// (0) must never expire anything.
|
|
|
|
const HOUR = 60 * 60 * 1000;
|
|
|
|
test('isExpired: an event older than the window is expired', () => {
|
|
const now = 10 * HOUR;
|
|
assert.equal(isExpired(now - 2 * HOUR, HOUR, now), true);
|
|
});
|
|
|
|
test('isExpired: an event within the window is NOT expired', () => {
|
|
const now = 10 * HOUR;
|
|
assert.equal(isExpired(now - HOUR / 2, HOUR, now), false);
|
|
});
|
|
|
|
test('isExpired: exactly at the boundary is NOT expired (strict >)', () => {
|
|
const now = 10 * HOUR;
|
|
assert.equal(isExpired(now - HOUR, HOUR, now), false);
|
|
});
|
|
|
|
test('isExpired: a disabled policy (0 / negative) never expires', () => {
|
|
const now = 10 * HOUR;
|
|
assert.equal(isExpired(now - 100 * HOUR, 0, now), false);
|
|
assert.equal(isExpired(0, -1, now), false);
|
|
});
|
|
|
|
test('presets: Off is 0 and the rest are strictly increasing, all >= the floor', () => {
|
|
assert.equal(RETENTION_PRESETS[0].ms, 0);
|
|
const nonZero = RETENTION_PRESETS.slice(1).map((p) => p.ms);
|
|
for (let i = 1; i < nonZero.length; i += 1) {
|
|
assert.ok(nonZero[i] > nonZero[i - 1], 'presets increase');
|
|
}
|
|
assert.ok(
|
|
nonZero.every((ms) => ms >= RETENTION_MIN_MS),
|
|
'all presets above the floor',
|
|
);
|
|
});
|
|
|
|
test('effectiveRetentionMs: tiny or invalid windows count as off (#298)', () => {
|
|
assert.equal(effectiveRetentionMs(1000), undefined, '1 s is a typo, not a policy');
|
|
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS - 1), undefined);
|
|
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS), RETENTION_MIN_MS);
|
|
assert.equal(effectiveRetentionMs(86_400_000), 86_400_000);
|
|
assert.equal(effectiveRetentionMs(0), undefined);
|
|
assert.equal(effectiveRetentionMs(-5), undefined);
|
|
assert.equal(effectiveRetentionMs('86400000'), undefined);
|
|
assert.equal(effectiveRetentionMs(Number.NaN), undefined);
|
|
});
|
|
|
|
test('retentionDeletesAllowed: no deletions in the first 24 h of a policy (#298)', () => {
|
|
const now = 1_000 * HOUR;
|
|
assert.equal(retentionDeletesAllowed(now - 2 * HOUR, now), false);
|
|
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS + 1, now), false);
|
|
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS, now), true);
|
|
assert.equal(retentionDeletesAllowed(now - 72 * HOUR, now), true);
|
|
});
|