190 lines
9.6 KiB
JavaScript
190 lines
9.6 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import {readFileSync} from 'node:fs';
|
|
import {stripTypeScriptTypes} from 'node:module';
|
|
import {resolve} from 'node:path';
|
|
import {pathToFileURL} from 'node:url';
|
|
import vm from 'node:vm';
|
|
import {test} from 'node:test';
|
|
|
|
const source = resolve(process.env.FLUXER_SOURCE || 'fluxer-src');
|
|
const desktop = resolve(source, 'fluxer_desktop');
|
|
const read = (file) => readFileSync(resolve(desktop, file), 'utf8');
|
|
const {parseGscordRelease} = await import(pathToFileURL(resolve(desktop, 'src/main/GscordRelease.ts')));
|
|
const compatibility = read('src/preload/GscordCompatibility.ts');
|
|
function evaluate(globals = {}) {
|
|
return vm.createContext({...globals});
|
|
}
|
|
function run(ts, context) {
|
|
vm.runInContext(stripTypeScriptTypes(ts).replace(/^export /gm, ''), context);
|
|
}
|
|
function rtcContext() {
|
|
class Peer {
|
|
constructor(config) { this.config = config; }
|
|
setConfiguration(config) { this.config = config; }
|
|
}
|
|
const context = evaluate({window: {RTCPeerConnection: Peer}, console: {info() {}}});
|
|
run(compatibility, context);
|
|
context.installForceAllIceTransportPolicy();
|
|
return {context, Peer, Wrapped: context.window.RTCPeerConnection};
|
|
}
|
|
|
|
test('URLs, homepage and initial server use GSCORD', () => {
|
|
const constants = read('src/common/Constants.ts');
|
|
for (const name of ['STABLE_APP_URL', 'CANARY_APP_URL', 'STABLE_MIGRATED_APP_ORIGIN', 'CANARY_MIGRATED_APP_ORIGIN']) {
|
|
assert.ok(constants.includes(`export const ${name} = 'https://ds.gandc.ru';`));
|
|
}
|
|
assert.equal(JSON.parse(read('package.json')).homepage, 'https://ds.gandc.ru');
|
|
const discovery = read('src/main/DesktopRuntimeDiscovery.ts');
|
|
const start = discovery.indexOf('export function desktopRuntimeInitialInput');
|
|
const code = discovery.slice(start, discovery.indexOf('\nasync function fetchRuntimePlan', start));
|
|
let override = null;
|
|
const context = evaluate({BUILD_CHANNEL: 'stable', CHANNEL_APP_URLS: {stable: 'https://ds.gandc.ru'},
|
|
getLaunchInstanceEndpointOverride: () => override});
|
|
run(code, context);
|
|
assert.equal(context.desktopRuntimeInitialInput(), 'https://ds.gandc.ru');
|
|
override = 'https://another.example';
|
|
assert.equal(context.desktopRuntimeInitialInput(), override);
|
|
});
|
|
|
|
test('ICE constructor overrides relay without mutating caller configuration', () => {
|
|
const {Peer, Wrapped} = rtcContext();
|
|
const config = Object.freeze({iceTransportPolicy: 'relay', iceServers: [{urls: 'stun:example.test'}]});
|
|
const peer = new Wrapped(config);
|
|
assert.equal(peer.config.iceTransportPolicy, 'all');
|
|
assert.equal(config.iceTransportPolicy, 'relay');
|
|
assert.equal(peer.config.iceServers, config.iceServers);
|
|
assert.ok(peer instanceof Peer);
|
|
assert.ok(peer instanceof Wrapped);
|
|
});
|
|
|
|
test('ICE setConfiguration overrides relay and preserves native validation path', () => {
|
|
const {Wrapped} = rtcContext();
|
|
const peer = new Wrapped();
|
|
const config = Object.freeze({iceTransportPolicy: 'relay'});
|
|
peer.setConfiguration(config);
|
|
assert.equal(peer.config.iceTransportPolicy, 'all');
|
|
assert.equal(config.iceTransportPolicy, 'relay');
|
|
const other = {iceTransportPolicy: 'all'};
|
|
peer.setConfiguration(other);
|
|
assert.equal(peer.config, other);
|
|
peer.setConfiguration(undefined);
|
|
assert.equal(peer.config, undefined);
|
|
});
|
|
|
|
test('ICE proxy preserves subclass construction and default configuration', () => {
|
|
const {Wrapped} = rtcContext();
|
|
class Derived extends Wrapped {}
|
|
const peer = new Derived();
|
|
assert.ok(peer instanceof Derived);
|
|
assert.equal(peer.config, undefined);
|
|
});
|
|
|
|
test('ICE installer tolerates missing WebRTC', () => {
|
|
const context = evaluate({window: {}, console});
|
|
run(compatibility, context);
|
|
context.installForceAllIceTransportPolicy();
|
|
});
|
|
|
|
test('LiveKit workaround restores compression for RTC socket and suppresses it on close', () => {
|
|
class Compression {}
|
|
class Socket {
|
|
constructor(url) { this.url = url; }
|
|
addEventListener(name, callback) { this[name] = callback; }
|
|
}
|
|
const window = {WebSocket: Socket, CompressionStream: Compression, location: {href: 'https://ds.gandc.ru'}};
|
|
const context = evaluate({window, URL, console: {info() {}}});
|
|
run(compatibility, context);
|
|
context.installDisableLiveKitOfferWithJoin();
|
|
assert.equal(window.CompressionStream, undefined);
|
|
new window.WebSocket('wss://ds.gandc.ru/gateway');
|
|
assert.equal(window.CompressionStream, undefined);
|
|
const rtc = new window.WebSocket('wss://voice.example/rtc/v1?token=test');
|
|
assert.equal(window.CompressionStream, Compression);
|
|
rtc.close();
|
|
assert.equal(window.CompressionStream, undefined);
|
|
});
|
|
|
|
test('preload installs both self-contained callbacks in main world', () => {
|
|
const preload = read('src/preload/index.ts');
|
|
for (const name of ['installForceAllIceTransportPolicy', 'installDisableLiveKitOfferWithJoin']) {
|
|
assert.ok(preload.includes(`contextBridge.executeInMainWorld({func: ${name}});`));
|
|
}
|
|
});
|
|
|
|
test('navigation allows exact SSO and callback origins but rejects lookalikes and reserved paths', () => {
|
|
const windowCode = read('src/main/Window.ts');
|
|
const start = windowCode.indexOf('function isAllowedSsoNavigation');
|
|
const end = windowCode.indexOf('function preventUntrustedChildNavigation', start);
|
|
const local = 'fluxer://app/';
|
|
const context = evaluate({URL, logger: {warn() {}},
|
|
isLocalAppURL: (url) => url.startsWith(local),
|
|
isLocalAppRendererDocumentURL: (url) => url === `${local}channels/@me`,
|
|
isTrustedOrigin: (url) => url === `${local}channels/@me`});
|
|
run(windowCode.slice(start, end), context);
|
|
for (const [url, blocked] of [
|
|
['https://sso.gandc.ru/login', false], ['https://ds.gandc.ru/callback', false],
|
|
[`${local}channels/@me`, false], [`${local}__reserved`, true],
|
|
['https://sso.gandc.ru.evil.test', true], ['http://sso.gandc.ru', true],
|
|
['https://sso.gandc.ru:8443', true], ['https://sso.gandc.ru@evil.test', true], ['invalid', true],
|
|
]) {
|
|
let prevented = false;
|
|
context.preventUntrustedNavigation({preventDefault() { prevented = true; }}, url, true);
|
|
assert.equal(prevented, blocked, url);
|
|
}
|
|
assert.equal((windowCode.match(/webContents.on\('will-redirect'/g) || []).length, 2);
|
|
const trusted = windowCode.slice(windowCode.indexOf('export function isTrustedOrigin'), windowCode.indexOf('function getSanitizedPath'));
|
|
assert.ok(!trusted.includes('isAllowedSsoNavigation'));
|
|
});
|
|
|
|
const asset = (name) => ({name, browser_download_url: `https://git.gandc.ru/assets/${name}`});
|
|
const release = {tag_name: 'v1.0.99', published_at: '2026-10-11T00:00:00Z', assets: [
|
|
asset('Fluxer-1.0.99-linux-x86_64.AppImage'), asset('Fluxer-1.0.99-linux-amd64.deb'),
|
|
asset('Fluxer-1.0.99-linux-arm64.AppImage'), asset('Fluxer-1.0.99-linux-arm64.deb'),
|
|
asset('Fluxer-1.0.99-portable-win-x64.exe'), asset('Fluxer-1.0.99-win-arm64.exe'),
|
|
asset('unrelated.zip'), null, {name: 'broken'},
|
|
]};
|
|
|
|
test('Gitea parser selects only the matching platform and architecture', () => {
|
|
for (const arch of ['x64', 'arm64']) {
|
|
const linux = parseGscordRelease(release, 'linux', arch);
|
|
assert.equal(linux.version, '1.0.99');
|
|
assert.equal(linux.pubDate, release.published_at);
|
|
assert.deepEqual(Object.keys(linux.files).sort(), ['appimage', 'deb']);
|
|
assert.ok(linux.files.appimage.url.includes(arch === 'x64' ? 'x86_64' : 'arm64'));
|
|
const win = parseGscordRelease(release, 'win32', arch);
|
|
assert.deepEqual(Object.keys(win.files), ['setup']);
|
|
assert.ok(win.files.setup.url.endsWith(`win-${arch}.exe`));
|
|
}
|
|
});
|
|
|
|
test('Gitea parser accepts current portable filename and handles absent or malformed assets', () => {
|
|
const parsed = parseGscordRelease({tag_name: '1.0.100', assets: [asset('Fluxer-1.0.100-win-x64.exe')]}, 'win32', 'x64');
|
|
assert.ok(parsed.files.setup);
|
|
assert.deepEqual(parseGscordRelease({tag_name: 'v1.0.1'}, 'linux', 'x64').files, {});
|
|
for (const payload of [null, {}, {tag_name: ' '}, {tag_name: 'v'}]) assert.throws(() => parseGscordRelease(payload));
|
|
});
|
|
|
|
test('manual updater offers actual Gitea assets rather than fabricated URLs', () => {
|
|
let code = read('src/main/UpdaterDownloads.ts').replace(/^import[\s\S]*?from '[^']+';\n/gm, '');
|
|
const context = evaluate({process: {platform: 'linux'}, DESKTOP_DOWNLOAD_ARCH: 'x64',
|
|
DESKTOP_ARTIFACT_PRODUCT_NAME: 'Fluxer', LINUX_MANUAL_ARCH_TOKENS: {deb: {x64: 'amd64'}},
|
|
LINUX_MANUAL_FORMAT_EXTENSIONS: {deb: '.deb'}, isLinuxManualDesktopFormat: () => true});
|
|
run(code, context);
|
|
const info = {version: '1.0.99', files: {deb: {url: 'https://git.gandc.ru/assets/real.deb', sha256: null}}};
|
|
const options = context.getManualDownloadOptions(info);
|
|
assert.equal(options.length, 1);
|
|
assert.equal(options[0].url, info.files.deb.url);
|
|
assert.equal(context.getManualDownloadUrl({version: '1.0.99', files: {}}), 'https://git.gandc.ru/gscord/gscord-desktop/releases');
|
|
});
|
|
|
|
test('GSCORD portable Windows and Linux including AppImage use manual updates', () => {
|
|
const code = read('src/main/ShellUpdateCapability.ts');
|
|
const start = code.indexOf('export function decideShellUpdatePlan');
|
|
const context = evaluate({ShellUpdateCapability: {MANUAL_DOWNLOAD: 'manual', MANAGED_PACKAGE: 'managed', SELF_UPDATE: 'self'}});
|
|
run(code.slice(start, code.indexOf('function resolveRunningAppImage', start)), context);
|
|
const runtime = {packaged: true, channel: 'stable', flatpak: false, portable: false, appImage: {path: 'test'}};
|
|
for (const platform of ['win32', 'linux']) assert.equal(context.decideShellUpdatePlan({...runtime, platform}).capability, 'manual');
|
|
assert.equal(context.decideShellUpdatePlan({...runtime, platform: 'linux', flatpak: true}).capability, 'managed');
|
|
assert.equal(context.decideShellUpdatePlan({...runtime, platform: 'linux', packaged: false}).reason, 'unpackaged');
|
|
});
|