Fix wonkyness #18
@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## Lessons
|
||||||
|
|
||||||
|
### Match direct-call recipients against every local identity alias, exactly like DMs already do [direct-call] [identity]
|
||||||
|
|
||||||
|
- **Trigger:** "User receiving direct call doesn't get notified" — a caller who met the callee through a room on the caller's signal server addressed the ring by the callee's *provisioned actor id*; `handleIncomingCallEvent` admitted only `payload.participantIds.includes(oderId || id)`, so the ring was silently dropped, the caller sat "In Voice", and the callee saw nothing. DMs had the identical bug fixed earlier (`baa350e`), but the fix stopped at `DirectMessageService` and never reached `DirectCallService`.
|
||||||
|
- **Rule:** every self check on a cross-user event (admission, sender-echo filter, remote-participant filtering, DM-header peer lookup) must span all local aliases — home id, entity id, peer id, plus each `SignalServerCredentialStoreService.listValidCredentials()` actor id — and incoming aliases must be normalized onto the canonical local id before session state is keyed (`normalizeDirectCallPayloadSelfAliases`).
|
||||||
|
- **Why:** the failure only reproduces when caller and callee have different home signal servers, which no same-server e2e covers; and when one identity-alias bug is fixed in a domain, grep for the same `=== currentUserId` pattern in sibling domains that share the transport — the direct-call domain reused `PeerDeliveryService` but kept the naive check for another month.
|
||||||
|
- **Example:** `direct-call-participant-identity.rules.ts#directCallPayloadIncludesAnyId` / `normalizeDirectCallPayloadSelfAliases`; regression e2e `e2e/tests/voice/dm-header-call-ring.spec.ts` registers Bob on a secondary signal server, meets in a primary-signal room, and asserts the DM-header call rings Bob's incoming-call modal (fails on old code, passes after).
|
||||||
|
|
||||||
### Decide attachment receive admission once at request time; never re-gate size in the chunk handler [attachments]
|
### Decide attachment receive admission once at request time; never re-gate size in the chunk handler [attachments]
|
||||||
|
|
||||||
- **Trigger:** "Sending files between users doesn't really work" — a browser user clicked Request on a 10–50 MB generic file, the request gate (`canReceiveAttachment`) admitted it for in-memory receive, the sender streamed chunks, but `handleFileChunk` still had a leftover hard `size > MAX_AUTO_SAVE_SIZE_BYTES` rejection on the in-memory path, so every chunk was dropped, no ack was ever sent, the sender's `waitForAck` timed out, and the GUI never changed.
|
- **Trigger:** "Sending files between users doesn't really work" — a browser user clicked Request on a 10–50 MB generic file, the request gate (`canReceiveAttachment`) admitted it for in-memory receive, the sender streamed chunks, but `handleFileChunk` still had a leftover hard `size > MAX_AUTO_SAVE_SIZE_BYTES` rejection on the in-memory path, so every chunk was dropped, no ack was ever sent, the sender's `waitForAck` timed out, and the GUI never changed.
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../../fixtures/multi-client';
|
||||||
|
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||||
|
import { startTestServer } from '../../helpers/test-server';
|
||||||
|
import { readSignalServerCredentialFromPage } from '../../helpers/auth-api';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for "User receiving direct call doesn't get notified":
|
||||||
|
* starting a call from the DM chat header (steps: open DM of a user, click
|
||||||
|
* call) must ring the recipient - incoming-call modal, ring audio, and a
|
||||||
|
* server-rail call entry. Includes the cross-signal topology where the callee
|
||||||
|
* is addressed by a provisioned actor id instead of their home identity.
|
||||||
|
*/
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const PRIMARY_SIGNAL_ID = 'e2e-dm-ring-primary';
|
||||||
|
const SECONDARY_SIGNAL_ID = 'e2e-dm-ring-secondary';
|
||||||
|
|
||||||
|
test.describe('DM header call ring', () => {
|
||||||
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
|
test('callee is notified when the caller starts the call from the DM chat header', async ({ createClient }) => {
|
||||||
|
const suffix = uniqueName('dm-ring');
|
||||||
|
const serverName = `DM Ring Server ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
|
||||||
|
await installRingInstrumentation(bob.page);
|
||||||
|
|
||||||
|
await test.step('Alice and Bob register and meet in a server', async () => {
|
||||||
|
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
||||||
|
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
||||||
|
|
||||||
|
const aliceSearch = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearch.createServer(serverName, { description: 'DM header call ring regression coverage' });
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(alice.page).waitForReady();
|
||||||
|
|
||||||
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(bob.page).waitForReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Both users open the DM view; live DM delivery confirms the transport works', async () => {
|
||||||
|
const bobUserCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||||
|
|
||||||
|
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const aliceUserCard = bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' }).first();
|
||||||
|
|
||||||
|
await expect(aliceUserCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await aliceUserCard.getByRole('button', { name: 'Message Alice' }).click();
|
||||||
|
await expect(bob.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Mirrors the bug report: the users are in the DM view (not a server
|
||||||
|
// room) when the call starts. The message must arrive live so a broken
|
||||||
|
// ring cannot be blamed on a dead transport.
|
||||||
|
await alice.page.getByTestId('dm-input').fill(`hello before call ${suffix}`);
|
||||||
|
await alice.page.getByTestId('dm-input').press('Enter');
|
||||||
|
await expect(bob.page.locator('app-dm-chat').getByText(`hello before call ${suffix}`)).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice starts the call from the DM chat header', async () => {
|
||||||
|
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||||
|
|
||||||
|
await expect(callButton).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||||
|
await callButton.click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob gets the incoming-call modal, ring audio, and rail entry', async () => {
|
||||||
|
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await getCallAudioPlayCount(bob.page), {
|
||||||
|
timeout: 20_000,
|
||||||
|
intervals: [500, 1_000]
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callee homed on another signal server is notified when called via their provisioned actor id', async ({ createClient, testServer }) => {
|
||||||
|
const secondaryServer = await startTestServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suffix = uniqueName('xsig-ring');
|
||||||
|
const serverName = `Cross Signal Ring ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
const endpoints = [
|
||||||
|
{
|
||||||
|
id: PRIMARY_SIGNAL_ID,
|
||||||
|
name: 'E2E Ring Signal A',
|
||||||
|
url: testServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SECONDARY_SIGNAL_ID,
|
||||||
|
name: 'E2E Ring Signal B',
|
||||||
|
url: secondaryServer.url,
|
||||||
|
isActive: true,
|
||||||
|
status: 'online'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await installTestServerEndpoints(alice.context, endpoints);
|
||||||
|
await installTestServerEndpoints(bob.context, endpoints);
|
||||||
|
await installRingInstrumentation(bob.page);
|
||||||
|
|
||||||
|
await test.step('Alice registers on the primary signal, Bob on the secondary', async () => {
|
||||||
|
const aliceRegister = new RegisterPage(alice.page);
|
||||||
|
|
||||||
|
await aliceRegister.goto();
|
||||||
|
await aliceRegister.serverSelect.selectOption(PRIMARY_SIGNAL_ID);
|
||||||
|
await aliceRegister.register(`alice_${suffix}`, 'Alice', USER_PASSWORD);
|
||||||
|
await expect(alice.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
|
||||||
|
const bobRegister = new RegisterPage(bob.page);
|
||||||
|
|
||||||
|
await bobRegister.goto();
|
||||||
|
await bobRegister.serverSelect.selectOption(SECONDARY_SIGNAL_ID);
|
||||||
|
await bobRegister.register(`bob_${suffix}`, 'Bob', USER_PASSWORD);
|
||||||
|
await expect(bob.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('They meet in a room on the primary signal; Bob gets a provisioned actor identity', async () => {
|
||||||
|
const aliceSearch = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearch.createServer(serverName, {
|
||||||
|
description: 'Cross-signal DM call ring coverage',
|
||||||
|
sourceId: PRIMARY_SIGNAL_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(alice.page).waitForReady();
|
||||||
|
|
||||||
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
await new ChatMessagesPage(bob.page).waitForReady();
|
||||||
|
|
||||||
|
await expect.poll(async () =>
|
||||||
|
await readSignalServerCredentialFromPage(bob.page, testServer.url),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice opens the DM with Bob and calls from the DM chat header', async () => {
|
||||||
|
const bobUserCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||||
|
|
||||||
|
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||||
|
|
||||||
|
const callButton = alice.page.locator('app-dm-chat header').getByRole('button', { name: 'Call Bob' });
|
||||||
|
|
||||||
|
await expect(callButton).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(callButton).toBeEnabled({ timeout: 20_000 });
|
||||||
|
await callButton.click();
|
||||||
|
await expect(alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob gets the incoming-call modal and ring audio', async () => {
|
||||||
|
await expect(bob.page.getByRole('dialog', { name: /is calling/ })).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await getCallAudioPlayCount(bob.page), {
|
||||||
|
timeout: 20_000,
|
||||||
|
intervals: [500, 1_000]
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await secondaryServer.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
|
||||||
|
const registerPage = new RegisterPage(page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(username, displayName, USER_PASSWORD);
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRingInstrumentation(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const OriginalAudio = window.Audio;
|
||||||
|
const callAudioState = { playCount: 0 };
|
||||||
|
|
||||||
|
(window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState;
|
||||||
|
|
||||||
|
function isCallAudio(audio: HTMLAudioElement): boolean {
|
||||||
|
return audio.src.includes('/assets/audio/call.wav') || audio.src.endsWith('assets/audio/call.wav');
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as unknown as { Audio: typeof Audio }).Audio = function(this: HTMLAudioElement, src?: string) {
|
||||||
|
const audio = new OriginalAudio(src);
|
||||||
|
const originalPlay = audio.play.bind(audio);
|
||||||
|
|
||||||
|
audio.play = () => {
|
||||||
|
if (isCallAudio(audio)) {
|
||||||
|
callAudioState.playCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalPlay();
|
||||||
|
};
|
||||||
|
|
||||||
|
return audio;
|
||||||
|
} as typeof Audio;
|
||||||
|
|
||||||
|
window.Audio.prototype = OriginalAudio.prototype;
|
||||||
|
Object.setPrototypeOf(window.Audio, OriginalAudio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCallAudioPlayCount(page: Page): Promise<number> {
|
||||||
|
return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
}
|
||||||
@@ -14,6 +14,6 @@ Direct calls coordinate private voice sessions started from people cards, direct
|
|||||||
8. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.
|
8. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.
|
||||||
9. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view.
|
9. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view.
|
||||||
|
|
||||||
Incoming `direct-call` events are ignored unless the current user is declared in the event's `participantIds` or participant profiles, so only invited PM/group-call participants can receive call audio, the in-app incoming-call modal, or a desktop ring notification.
|
Incoming `direct-call` events are ignored unless the current user is declared in the event's `participantIds` or participant profiles, so only invited PM/group-call participants can receive call audio, the in-app incoming-call modal, or a desktop ring notification. That declaration check — and every other self check (sender echo filter, `remoteParticipantIds`, the DM-header peer lookup) — must match **every local identity alias**: home id, entity id, peer id, and each provisioned signal-server actor id from `SignalServerCredentialStoreService`. A caller who met the callee on a foreign signal server addresses them by the provisioned actor id, not the home id; checking only `oderId || id` silently drops the ring while the caller's UI moves to "In Voice" (`normalizeDirectCallPayloadSelfAliases` in `direct-call-participant-identity.rules.ts` collapses those aliases onto the canonical local id before session state is built, so the alias never appears as a phantom third participant).
|
||||||
|
|
||||||
Two-person calls use the one-to-one direct-message conversation id as their call id. Converted group calls keep the original call id for media routing but point `conversationId` at the new group chat so active streams stay connected while the chat history boundary changes.
|
Two-person calls use the one-to-one direct-message conversation id as their call id. Converted group calls keep the original call id for media routing but point `conversationId` at the new group chat so active streams stay connected while the chat history boundary changes.
|
||||||
|
|||||||
+122
@@ -17,6 +17,7 @@ import {
|
|||||||
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
|
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
|
||||||
import { ViewportService } from '../../../../core/platform';
|
import { ViewportService } from '../../../../core/platform';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
|
||||||
import {
|
import {
|
||||||
VoiceActivityService,
|
VoiceActivityService,
|
||||||
VoiceConnectionFacade,
|
VoiceConnectionFacade,
|
||||||
@@ -110,6 +111,111 @@ describe('DirectCallService', () => {
|
|||||||
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
|
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('notifies when a ring addresses the local user via a provisioned signal-server actor id', async () => {
|
||||||
|
// Bob's home identity is "bob", but on the caller's signal server he acts
|
||||||
|
// through the provisioned identity "bob-actor". The ring payload only
|
||||||
|
// carries the actor id, so admission must match every local alias.
|
||||||
|
const context = createServiceContext({
|
||||||
|
currentUser: bob,
|
||||||
|
allUsers: [alice, bob],
|
||||||
|
selfActorIds: ['bob-actor']
|
||||||
|
});
|
||||||
|
|
||||||
|
context.directCallEvents.next({
|
||||||
|
type: 'direct-call',
|
||||||
|
directCall: {
|
||||||
|
action: 'ring',
|
||||||
|
callId: 'dm-alice-bob-actor',
|
||||||
|
conversationId: 'dm-alice-bob-actor',
|
||||||
|
createdAt: 10,
|
||||||
|
sender: toParticipant(alice),
|
||||||
|
participantIds: ['alice', 'bob-actor'],
|
||||||
|
participants: [toParticipant(alice), { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob-actor'));
|
||||||
|
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
|
||||||
|
|
||||||
|
const session = context.service.sessionById('dm-alice-bob-actor');
|
||||||
|
|
||||||
|
// The actor alias must collapse onto the local user instead of appearing
|
||||||
|
// as a third participant (which would convert the call into a group chat).
|
||||||
|
expect(session?.participantIds.sort()).toEqual(['alice', 'bob']);
|
||||||
|
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores rings echoed back to the sender through a provisioned actor alias', async () => {
|
||||||
|
const context = createServiceContext({
|
||||||
|
currentUser: bob,
|
||||||
|
allUsers: [alice, bob],
|
||||||
|
selfActorIds: ['bob-actor']
|
||||||
|
});
|
||||||
|
|
||||||
|
context.directCallEvents.next({
|
||||||
|
type: 'direct-call',
|
||||||
|
directCall: {
|
||||||
|
action: 'ring',
|
||||||
|
callId: 'dm-alice-bob',
|
||||||
|
conversationId: 'dm-alice-bob',
|
||||||
|
createdAt: 10,
|
||||||
|
sender: { userId: 'bob-actor', username: 'bob', displayName: 'Bob' },
|
||||||
|
participantIds: ['alice', 'bob-actor'],
|
||||||
|
participants: [toParticipant(alice), { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(context.service.sessionById('dm-alice-bob')).toBeNull();
|
||||||
|
expect(context.audio.playLoop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes provisioned actor aliases from remote participant ids', () => {
|
||||||
|
const context = createServiceContext({
|
||||||
|
currentUser: bob,
|
||||||
|
allUsers: [alice, bob],
|
||||||
|
selfActorIds: ['bob-actor']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.service.remoteParticipantIds({
|
||||||
|
...createSession('ringing', false),
|
||||||
|
participantIds: [
|
||||||
|
'alice',
|
||||||
|
'bob',
|
||||||
|
'bob-actor'
|
||||||
|
]
|
||||||
|
})).toEqual(['alice']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a DM-header call to the peer even when the conversation stores the local user under an actor alias', async () => {
|
||||||
|
const context = createServiceContext({
|
||||||
|
currentUser: bob,
|
||||||
|
allUsers: [alice, bob],
|
||||||
|
selfActorIds: ['bob-actor']
|
||||||
|
});
|
||||||
|
const conversation: DirectMessageConversation = {
|
||||||
|
id: 'dm-alice-bob-actor',
|
||||||
|
kind: 'direct',
|
||||||
|
lastMessageAt: 10,
|
||||||
|
messages: [],
|
||||||
|
participantProfiles: {
|
||||||
|
'alice': toParticipant(alice),
|
||||||
|
'bob-actor': { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }
|
||||||
|
},
|
||||||
|
participants: ['alice', 'bob-actor'],
|
||||||
|
unreadCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
context.service.joinCall = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
await context.service.startConversationCall(conversation);
|
||||||
|
|
||||||
|
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('alice', expect.objectContaining({
|
||||||
|
directCall: expect.objectContaining({ action: 'ring' }),
|
||||||
|
type: 'direct-call'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('marks a remote join against the session participant alias stored locally', async () => {
|
it('marks a remote join against the session participant alias stored locally', async () => {
|
||||||
const aliceForeign = createUser('alice-foreign', 'Alice');
|
const aliceForeign = createUser('alice-foreign', 'Alice');
|
||||||
const bobForeign = createUser('bob-foreign', 'Bob');
|
const bobForeign = createUser('bob-foreign', 'Bob');
|
||||||
@@ -429,6 +535,7 @@ describe('DirectCallService', () => {
|
|||||||
interface ServiceContextOptions {
|
interface ServiceContextOptions {
|
||||||
allUsers: User[];
|
allUsers: User[];
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
|
selfActorIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceContext {
|
interface ServiceContext {
|
||||||
@@ -536,6 +643,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|||||||
const voiceSession = {
|
const voiceSession = {
|
||||||
endSession: vi.fn()
|
endSession: vi.fn()
|
||||||
};
|
};
|
||||||
|
const credentialStore = {
|
||||||
|
listValidCredentials: vi.fn(() => (options.selfActorIds ?? []).map((userId) => ({
|
||||||
|
serverUrl: `https://signal.example/${userId}`,
|
||||||
|
userId,
|
||||||
|
username: userId,
|
||||||
|
displayName: userId,
|
||||||
|
token: 'token',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
provisioned: true
|
||||||
|
})))
|
||||||
|
};
|
||||||
const injector = Injector.create({
|
const injector = Injector.create({
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@@ -626,6 +744,10 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|||||||
requestVoiceClientTakeover: vi.fn()
|
requestVoiceClientTakeover: vi.fn()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: SignalServerCredentialStoreService,
|
||||||
|
useValue: credentialStore
|
||||||
|
},
|
||||||
...provideAppI18nForTests()
|
...provideAppI18nForTests()
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '../../../voice-connection';
|
} from '../../../voice-connection';
|
||||||
import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
|
import { VoiceSessionFacade, isVoiceOnAnotherClient } from '../../../voice-session';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
|
||||||
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
||||||
import type { DirectMessageConversation } from '../../../direct-message';
|
import type { DirectMessageConversation } from '../../../direct-message';
|
||||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
@@ -34,9 +35,12 @@ import {
|
|||||||
} from '../../../../shared-kernel';
|
} from '../../../../shared-kernel';
|
||||||
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
|
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
|
||||||
import {
|
import {
|
||||||
|
collectDirectCallUserIdentityKeys,
|
||||||
|
directCallPayloadIncludesAnyId,
|
||||||
findDirectCallParticipantEntry,
|
findDirectCallParticipantEntry,
|
||||||
findDirectCallParticipantEntryForUser,
|
findDirectCallParticipantEntryForUser,
|
||||||
isDirectCallParticipantJoined
|
isDirectCallParticipantJoined,
|
||||||
|
normalizeDirectCallPayloadSelfAliases
|
||||||
} from '../../domain/logic/direct-call-participant-identity.rules';
|
} from '../../domain/logic/direct-call-participant-identity.rules';
|
||||||
import { toDirectMessageParticipant } from '../../../direct-message';
|
import { toDirectMessageParticipant } from '../../../direct-message';
|
||||||
|
|
||||||
@@ -56,6 +60,7 @@ export class DirectCallService {
|
|||||||
private readonly mobileNotifications = inject(MobileNotificationsService);
|
private readonly mobileNotifications = inject(MobileNotificationsService);
|
||||||
private readonly mobileCallSession = inject(MobileCallSessionService);
|
private readonly mobileCallSession = inject(MobileCallSessionService);
|
||||||
private readonly mobileMedia = inject(MobileMediaService);
|
private readonly mobileMedia = inject(MobileMediaService);
|
||||||
|
private readonly credentialStore = inject(SignalServerCredentialStoreService);
|
||||||
private readonly i18n = inject(AppI18nService);
|
private readonly i18n = inject(AppI18nService);
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||||
@@ -234,8 +239,8 @@ export class DirectCallService {
|
|||||||
return await this.startGroupCall(conversation);
|
return await this.startGroupCall(conversation);
|
||||||
}
|
}
|
||||||
|
|
||||||
const meId = this.currentUserId();
|
const selfIds = this.selfIdentityIds();
|
||||||
const peerId = conversation.participants.find((participantId) => participantId !== meId);
|
const peerId = conversation.participants.find((participantId) => !selfIds.has(participantId));
|
||||||
|
|
||||||
if (!peerId) {
|
if (!peerId) {
|
||||||
throw new Error(this.i18n.instant('call.errors.noRecipient'));
|
throw new Error(this.i18n.instant('call.errors.noRecipient'));
|
||||||
@@ -433,9 +438,9 @@ export class DirectCallService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remoteParticipantIds(session: DirectCallSession): string[] {
|
remoteParticipantIds(session: DirectCallSession): string[] {
|
||||||
const meId = this.currentUserId();
|
const selfIds = this.selfIdentityIds();
|
||||||
|
|
||||||
return session.participantIds.filter((participantId) => participantId !== meId);
|
return session.participantIds.filter((participantId) => !selfIds.has(participantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
userForParticipant(participantId: string): User | null {
|
userForParticipant(participantId: string): User | null {
|
||||||
@@ -464,29 +469,35 @@ export class DirectCallService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
|
private async handleIncomingCallEvent(rawPayload: DirectCallEventPayload): Promise<void> {
|
||||||
const meId = this.currentUserId();
|
const meId = this.currentUserId();
|
||||||
|
|
||||||
if (!meId) {
|
if (!meId) {
|
||||||
if (payload.action === 'ring') {
|
if (rawPayload.action === 'ring') {
|
||||||
this.pendingIncomingCallPayloads.push(payload);
|
this.pendingIncomingCallPayloads.push(rawPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.sender.userId === meId) {
|
// Callers on a foreign signal server address the local user through the
|
||||||
|
// provisioned actor identity, not the home id, so every self check and the
|
||||||
|
// stored participant state must work across all local identity aliases.
|
||||||
|
const selfIds = this.selfIdentityIds();
|
||||||
|
|
||||||
|
if (selfIds.has(rawPayload.sender.userId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.callPayloadIncludesParticipant(payload, meId)) {
|
if (!directCallPayloadIncludesAnyId(rawPayload, selfIds)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.action === 'ring' && this.declinedCallIds.has(payload.callId)) {
|
if (rawPayload.action === 'ring' && this.declinedCallIds.has(rawPayload.callId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = normalizeDirectCallPayloadSelfAliases(rawPayload, meId, selfIds);
|
||||||
const participants = this.callParticipantsFromPayload(payload);
|
const participants = this.callParticipantsFromPayload(payload);
|
||||||
const existing = this.sessionById(payload.callId);
|
const existing = this.sessionById(payload.callId);
|
||||||
const incomingSession = this.createSession({
|
const incomingSession = this.createSession({
|
||||||
@@ -826,11 +837,6 @@ export class DirectCallService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean {
|
|
||||||
return payload.participantIds.includes(participantId)
|
|
||||||
|| (payload.participants ?? []).some((participant) => participant.userId === participantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private groupConversationTitle(session: DirectCallSession): string {
|
private groupConversationTitle(session: DirectCallSession): string {
|
||||||
const names = Object.values(session.participants)
|
const names = Object.values(session.participants)
|
||||||
.map((participant) => participant.profile.displayName || participant.profile.username || participant.userId);
|
.map((participant) => participant.profile.displayName || participant.profile.username || participant.userId);
|
||||||
@@ -1056,6 +1062,19 @@ export class DirectCallService {
|
|||||||
return user ? this.userKey(user) : null;
|
return user ? this.userKey(user) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every id that can address the local user, including provisioned signal-server actor ids. */
|
||||||
|
private selfIdentityIds(): ReadonlySet<string> {
|
||||||
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorUserIds = this.credentialStore.listValidCredentials().map((credential) => credential.userId);
|
||||||
|
|
||||||
|
return new Set(collectDirectCallUserIdentityKeys(user, actorUserIds));
|
||||||
|
}
|
||||||
|
|
||||||
private requireCurrentUser(): User {
|
private requireCurrentUser(): User {
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
|||||||
+58
-1
@@ -1,8 +1,11 @@
|
|||||||
|
import type { DirectCallEventPayload } from '../../../../shared-kernel';
|
||||||
import type { DirectCallSession } from '../models/direct-call.model';
|
import type { DirectCallSession } from '../models/direct-call.model';
|
||||||
import {
|
import {
|
||||||
|
directCallPayloadIncludesAnyId,
|
||||||
findDirectCallParticipantEntry,
|
findDirectCallParticipantEntry,
|
||||||
findDirectCallParticipantEntryForUser,
|
findDirectCallParticipantEntryForUser,
|
||||||
isDirectCallParticipantJoined
|
isDirectCallParticipantJoined,
|
||||||
|
normalizeDirectCallPayloadSelfAliases
|
||||||
} from './direct-call-participant-identity.rules';
|
} from './direct-call-participant-identity.rules';
|
||||||
|
|
||||||
function createSession(participants: DirectCallSession['participants']): DirectCallSession {
|
function createSession(participants: DirectCallSession['participants']): DirectCallSession {
|
||||||
@@ -77,4 +80,58 @@ describe('direct-call-participant-identity.rules', () => {
|
|||||||
oderId: 'bob-foreign'
|
oderId: 'bob-foreign'
|
||||||
}, ['bob-foreign'])).toBe(false);
|
}, ['bob-foreign'])).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('directCallPayloadIncludesAnyId matches participant ids and participant profiles', () => {
|
||||||
|
const payload = createRingPayload();
|
||||||
|
|
||||||
|
expect(directCallPayloadIncludesAnyId(payload, new Set(['bob-actor']))).toBe(true);
|
||||||
|
expect(directCallPayloadIncludesAnyId(payload, new Set(['bob-profile-only']))).toBe(true);
|
||||||
|
expect(directCallPayloadIncludesAnyId(payload, new Set(['charlie']))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizeDirectCallPayloadSelfAliases collapses provisioned aliases onto the canonical local id', () => {
|
||||||
|
const normalized = normalizeDirectCallPayloadSelfAliases(createRingPayload(), 'bob-home', new Set([
|
||||||
|
'bob-home',
|
||||||
|
'bob-actor',
|
||||||
|
'bob-profile-only'
|
||||||
|
]));
|
||||||
|
|
||||||
|
expect(normalized.participantIds).toEqual(['alice', 'bob-home']);
|
||||||
|
expect(normalized.participants?.map((participant) => participant.userId)).toEqual(['alice', 'bob-home']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizeDirectCallPayloadSelfAliases leaves payloads without self aliases untouched', () => {
|
||||||
|
const payload = createRingPayload();
|
||||||
|
const normalized = normalizeDirectCallPayloadSelfAliases(payload, 'charlie', new Set(['charlie']));
|
||||||
|
|
||||||
|
expect(normalized.participantIds).toEqual(payload.participantIds);
|
||||||
|
expect(normalized.participants).toEqual(payload.participants);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createRingPayload(): DirectCallEventPayload {
|
||||||
|
return {
|
||||||
|
action: 'ring',
|
||||||
|
callId: 'dm-alice--bob-actor',
|
||||||
|
conversationId: 'dm-alice--bob-actor',
|
||||||
|
createdAt: 1,
|
||||||
|
sender: {
|
||||||
|
userId: 'alice',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice'
|
||||||
|
},
|
||||||
|
participantIds: ['alice', 'bob-actor'],
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
userId: 'alice',
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: 'bob-profile-only',
|
||||||
|
username: 'bob',
|
||||||
|
displayName: 'Bob'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+50
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { User } from '../../../../shared-kernel';
|
import type { DirectCallEventPayload, User } from '../../../../shared-kernel';
|
||||||
import type { DirectCallParticipant, DirectCallSession } from '../models/direct-call.model';
|
import type { DirectCallParticipant, DirectCallSession } from '../models/direct-call.model';
|
||||||
|
|
||||||
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
|
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
|
||||||
@@ -86,3 +86,52 @@ export function isDirectCallParticipantJoined(
|
|||||||
): boolean {
|
): boolean {
|
||||||
return !!findDirectCallParticipantEntryForUser(session, user, additionalIds)?.participant.joined;
|
return !!findDirectCallParticipantEntryForUser(session, user, additionalIds)?.participant.joined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when any of the given ids is declared in the payload's participant ids or profiles. */
|
||||||
|
export function directCallPayloadIncludesAnyId(
|
||||||
|
payload: Pick<DirectCallEventPayload, 'participantIds' | 'participants'>,
|
||||||
|
ids: ReadonlySet<string>
|
||||||
|
): boolean {
|
||||||
|
return payload.participantIds.some((participantId) => ids.has(participantId))
|
||||||
|
|| (payload.participants ?? []).some((participant) => ids.has(participant.userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite every self alias (home id, entity id, provisioned signal-server
|
||||||
|
* actor ids) in an incoming call payload to the canonical local id. Callers
|
||||||
|
* on a foreign signal server address the local user by the provisioned actor
|
||||||
|
* identity; without collapsing it the alias shows up as an extra third
|
||||||
|
* participant and never matches the local user's session key.
|
||||||
|
*/
|
||||||
|
export function normalizeDirectCallPayloadSelfAliases(
|
||||||
|
payload: DirectCallEventPayload,
|
||||||
|
canonicalId: string,
|
||||||
|
selfIds: ReadonlySet<string>
|
||||||
|
): DirectCallEventPayload {
|
||||||
|
const participantIds = [
|
||||||
|
...new Set(payload.participantIds.map((participantId) =>
|
||||||
|
(selfIds.has(participantId) ? canonicalId : participantId)))
|
||||||
|
];
|
||||||
|
const seenParticipantIds = new Set<string>();
|
||||||
|
const participants = payload.participants
|
||||||
|
?.map((participant) => (selfIds.has(participant.userId)
|
||||||
|
? {
|
||||||
|
...participant,
|
||||||
|
userId: canonicalId
|
||||||
|
}
|
||||||
|
: participant))
|
||||||
|
.filter((participant) => {
|
||||||
|
if (seenParticipantIds.has(participant.userId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenParticipantIds.add(participant.userId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
participantIds,
|
||||||
|
participants
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user