CI / Build & Quality Checks (pull_request) Successful in 3m34s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 17m37s
Each widget in the Widgets panel (and the open widget's header) gets an "Open in a new window" button, so a pad or board can sit beside the chat. It opens the same filled-in URL the frame uses (widgetCompleteUrl, now shared) with noopener,noreferrer, standalone as in Element. Only cross-origin https URLs get the button: room state can carry anything, e.g. a script URL from another client. Refs #205 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
140 lines
5.3 KiB
TypeScript
140 lines
5.3 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { MatrixCapabilities, Capability } from 'matrix-widget-api';
|
|
import {
|
|
validateWidgetUrl,
|
|
isWidgetUrlSafe,
|
|
filterWidgetCapabilities,
|
|
generateWidgetId,
|
|
classifyWidgetCapabilities,
|
|
loadWidgetConsent,
|
|
widgetPopoutUrl,
|
|
} from './widgetUtils';
|
|
|
|
const APP = 'https://chat.lotusguild.org';
|
|
|
|
test('validateWidgetUrl accepts a cross-origin https url', () => {
|
|
assert.equal(validateWidgetUrl('https://pad.example.org/p/room', APP), undefined);
|
|
});
|
|
|
|
test('validateWidgetUrl rejects empty / invalid / http / same-origin', () => {
|
|
assert.equal(validateWidgetUrl(' ', APP), 'empty');
|
|
assert.equal(validateWidgetUrl('not a url', APP), 'invalid');
|
|
assert.equal(validateWidgetUrl('http://example.org', APP), 'not-https');
|
|
assert.equal(validateWidgetUrl('https://chat.lotusguild.org/evil', APP), 'same-origin');
|
|
});
|
|
|
|
test('isWidgetUrlSafe rejects same-origin + garbage, accepts cross-origin', () => {
|
|
assert.equal(isWidgetUrlSafe('https://chat.lotusguild.org/x', APP), false);
|
|
assert.equal(isWidgetUrlSafe('https://other.example/x', APP), true);
|
|
assert.equal(isWidgetUrlSafe('garbage', APP), false);
|
|
});
|
|
|
|
test('filterWidgetCapabilities keeps only the benign allowlist', () => {
|
|
const requested = new Set<Capability>([
|
|
MatrixCapabilities.AlwaysOnScreen,
|
|
'm.send.event:m.room.message',
|
|
'org.matrix.msc2762.receive.state_event:m.room.member',
|
|
MatrixCapabilities.Screenshots,
|
|
]);
|
|
const allowed = filterWidgetCapabilities(requested);
|
|
assert.ok(allowed.has(MatrixCapabilities.AlwaysOnScreen));
|
|
assert.ok(allowed.has(MatrixCapabilities.Screenshots));
|
|
assert.equal(allowed.has('m.send.event:m.room.message'), false);
|
|
assert.equal(allowed.size, 2);
|
|
});
|
|
|
|
test('generateWidgetId is prefixed and unique across calls', () => {
|
|
const a = generateWidgetId();
|
|
const b = generateWidgetId();
|
|
assert.match(a, /^lotus_/);
|
|
assert.notEqual(a, b);
|
|
});
|
|
|
|
test('classifyWidgetCapabilities: display caps are automatic, event caps are asked', () => {
|
|
const { auto, ask } = classifyWidgetCapabilities([
|
|
MatrixCapabilities.AlwaysOnScreen,
|
|
'org.matrix.msc2762.receive.event:m.room.message#m.text',
|
|
'org.matrix.msc2762.send.event:m.reaction',
|
|
'org.matrix.msc2762.receive.state_event:m.room.topic',
|
|
]);
|
|
assert.deepEqual([...auto], [MatrixCapabilities.AlwaysOnScreen]);
|
|
assert.deepEqual(
|
|
ask.map((r) => [r.label, r.sends]),
|
|
[
|
|
['See messages of type “m.text” in this room', false],
|
|
['Send reactions in this room', true],
|
|
['See the room topic in this room', false],
|
|
],
|
|
);
|
|
});
|
|
|
|
test('classifyWidgetCapabilities never offers protected state, other rooms, to-device or account data', () => {
|
|
const { auto, ask } = classifyWidgetCapabilities([
|
|
'org.matrix.msc2762.send.state_event:m.room.power_levels',
|
|
'org.matrix.msc2762.send.state_event:m.room.join_rules',
|
|
'org.matrix.msc2762.send.state_event:m.room.encryption',
|
|
'org.matrix.msc2762.send.state_event:m.room.member#@a:x',
|
|
'org.matrix.msc2762.timeline:*',
|
|
'org.matrix.msc2762.timeline:!other:x',
|
|
'org.matrix.msc3819.send.to_device:m.custom',
|
|
'org.matrix.msc3819.receive.to_device:m.custom',
|
|
'org.matrix.msc4157.send.delayed_event',
|
|
'org.matrix.msc4039.upload_file',
|
|
'org.matrix.msc3973.user_directory_search',
|
|
'org.matrix.msc2762.receive.room_account_data:m.fully_read',
|
|
]);
|
|
assert.equal(auto.size, 0);
|
|
assert.deepEqual(ask, []);
|
|
});
|
|
|
|
test('classifyWidgetCapabilities: reading protected state is fine, changing unprotected state is asked', () => {
|
|
const { ask } = classifyWidgetCapabilities([
|
|
'org.matrix.msc2762.receive.state_event:m.room.power_levels',
|
|
'org.matrix.msc2762.send.state_event:m.room.topic',
|
|
'org.matrix.msc2762.send.state_event:com.example.board#main',
|
|
]);
|
|
assert.deepEqual(
|
|
ask.map((r) => r.label),
|
|
[
|
|
'See room permissions in this room',
|
|
'Change the room topic',
|
|
'Send “com.example.board” state (key “main”) in this room',
|
|
],
|
|
);
|
|
});
|
|
|
|
test('loadWidgetConsent tolerates missing storage', () => {
|
|
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
|
|
});
|
|
|
|
test('[#266] space structure, call membership and Lotus room policy can never be granted', () => {
|
|
const { auto, ask } = classifyWidgetCapabilities(
|
|
[
|
|
'm.space.child',
|
|
'm.space.parent',
|
|
'org.matrix.msc3401.call',
|
|
'org.matrix.msc3401.call.member',
|
|
'm.call.member',
|
|
'io.lotus.voice_limit',
|
|
'io.lotus.room_quality',
|
|
].map((t) => `org.matrix.msc2762.send.state_event:${t}`),
|
|
);
|
|
assert.equal(auto.size, 0);
|
|
assert.deepEqual(ask, []);
|
|
});
|
|
|
|
test('[#205] widgetPopoutUrl: only cross-origin https opens in a new window', () => {
|
|
// Built at runtime so the no-script-url lint rule doesn't flag the test data.
|
|
const scriptUrl = ['javascript', 'alert(1)'].join(':');
|
|
assert.equal(
|
|
widgetPopoutUrl('https://pad.example.org/p/room', APP),
|
|
'https://pad.example.org/p/room',
|
|
);
|
|
assert.equal(widgetPopoutUrl('https://chat.lotusguild.org/evil', APP), null, 'same origin');
|
|
assert.equal(widgetPopoutUrl('http://pad.example.org/', APP), null, 'not https');
|
|
assert.equal(widgetPopoutUrl(scriptUrl, APP), null, 'script URL');
|
|
assert.equal(widgetPopoutUrl('data:text/html,<b>x</b>', APP), null, 'data:');
|
|
assert.equal(widgetPopoutUrl('not a url', APP), null);
|
|
});
|