Expand tests with E2E coverage

This commit is contained in:
Jack Kingsman
2026-02-28 00:13:51 -08:00
parent ce99d63701
commit 60c0262490
16 changed files with 861 additions and 6 deletions

View File

@@ -181,6 +181,8 @@ export function markAllRead(): Promise<{ status: string; timestamp: number }> {
// --- Settings ---
export type Favorite = { type: string; id: string };
export interface BotConfig {
id: string;
name: string;
@@ -190,7 +192,7 @@ export interface BotConfig {
export interface AppSettings {
max_radio_contacts: number;
favorites: { type: string; id: string }[];
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: string;
last_message_times: Record<string, number>;
@@ -242,3 +244,34 @@ export async function waitForRadioConnected(
}
throw new Error(`Radio did not reconnect within ${timeoutMs}ms`);
}
// --- Contacts sync ---
export function syncContacts(): Promise<{ synced: number }> {
return fetchJson('/contacts/sync', { method: 'POST' });
}
// --- Packets / Historical decryption ---
export function getUndecryptedCount(): Promise<{ count: number }> {
return fetchJson('/packets/undecrypted/count');
}
export interface DecryptResult {
started: boolean;
total_packets: number;
message: string;
}
export function decryptHistorical(params: {
key_type: 'channel' | 'contact';
channel_key?: string;
channel_name?: string;
private_key?: string;
contact_public_key?: string;
}): Promise<DecryptResult> {
return fetchJson('/packets/decrypt/historical', {
method: 'POST',
body: JSON.stringify(params),
});
}

View File

@@ -78,6 +78,20 @@ def set_channel_last_read(key_hex: str, last_read: int | None):
conn.commit()
def inject_raw_packet(hex_data: str, payload_hash: str):
ts = int(time.time())
data_blob = bytes.fromhex(hex_data)
hash_blob = bytes.fromhex(payload_hash)
conn.execute(
"""
INSERT OR IGNORE INTO raw_packets (timestamp, data, payload_hash)
VALUES (?, ?, ?)
""",
(ts, data_blob, hash_blob),
)
conn.commit()
if payload['action'] == 'seed_channel':
name = payload['name']
key_hex = payload['key_hex']
@@ -102,6 +116,8 @@ elif payload['action'] == 'seed_unread':
(key_hex, text, ts, ts),
)
set_channel_last_read(key_hex, now - 10_000) # ensure unread
elif payload['action'] == 'inject_raw_packet':
inject_raw_packet(payload['hex_data'], payload['payload_hash'])
else:
raise SystemExit('unknown action')
@@ -140,6 +156,92 @@ export function seedChannelMessages(options: SeedOptions) {
return { key: keyHex };
}
interface EncryptedGroupTextOptions {
channelName: string; // e.g. "test" — will be prefixed with # if needed
senderName: string;
messageText: string;
timestamp?: number;
}
/**
* Build a raw MeshCore GROUP_TEXT packet encrypted with the channel key,
* matching the format expected by decoder.py `decrypt_group_text`.
*
* Packet layout:
* header(1) + path_len(1) + payload
* Where payload = channel_hash(1) + mac(2) + ciphertext
*
* Header byte for FLOOD + GROUP_TEXT: route_type=0x01, payload_type=0x05 → (0x05 << 2) | 0x01 = 0x15
*/
function buildEncryptedGroupTextPacket(options: EncryptedGroupTextOptions): {
rawHex: string;
payloadHash: string;
} {
const hashName = options.channelName.startsWith('#')
? options.channelName
: `#${options.channelName}`;
// channel_key = SHA256("#name")[:16]
const channelKeyFull = crypto.createHash('sha256').update(hashName).digest();
const channelKey = channelKeyFull.subarray(0, 16);
// channel_hash = SHA256(channel_key)[0]
const channelHash = crypto.createHash('sha256').update(channelKey).digest()[0];
// Build plaintext: timestamp(4 LE) + flags(1) + "sender: message\0"
const ts = options.timestamp ?? Math.floor(Date.now() / 1000);
const tsBuf = Buffer.alloc(4);
tsBuf.writeUInt32LE(ts, 0);
const flagsBuf = Buffer.from([0x00]);
const textStr = `${options.senderName}: ${options.messageText}\0`;
const textBuf = Buffer.from(textStr, 'utf-8');
const plainLen = 4 + 1 + textBuf.length;
const paddedLen = Math.ceil(plainLen / 16) * 16;
const plaintext = Buffer.alloc(paddedLen, 0);
tsBuf.copy(plaintext, 0);
flagsBuf.copy(plaintext, 4);
textBuf.copy(plaintext, 5);
// Encrypt: AES-128-ECB
const cipher = crypto.createCipheriv('aes-128-ecb', channelKey, null);
cipher.setAutoPadding(false);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
// MAC: HMAC-SHA256(channel_key + 16_zero_bytes, ciphertext)[:2]
const channelSecret = Buffer.concat([channelKey, Buffer.alloc(16, 0)]);
const mac = crypto.createHmac('sha256', channelSecret).update(ciphertext).digest().subarray(0, 2);
// Payload: channel_hash + mac + ciphertext
const payload = Buffer.concat([Buffer.from([channelHash]), mac, ciphertext]);
// Raw packet: header(0x15 = FLOOD + GROUP_TEXT) + path_len(0x00) + payload
const rawPacket = Buffer.concat([Buffer.from([0x15, 0x00]), payload]);
// payload_hash for dedup: SHA256 of the payload portion
const payloadHash = crypto.createHash('sha256').update(payload).digest().toString('hex');
return {
rawHex: rawPacket.toString('hex'),
payloadHash,
};
}
/**
* Build an encrypted GROUP_TEXT packet and inject it into the raw_packets table.
*/
export function injectEncryptedGroupText(options: EncryptedGroupTextOptions) {
const { rawHex, payloadHash } = buildEncryptedGroupTextPacket(options);
runPython({
action: 'inject_raw_packet',
root: ROOT,
db_path: DB_PATH,
hex_data: rawHex,
payload_hash: payloadHash,
});
return { rawHex, payloadHash };
}
export function seedChannelUnread(options: SeedReadStateOptions) {
const keyHex = channelKeyFromName(
options.channelName.startsWith('#') ? options.channelName : `#${options.channelName}`

View File

@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import {
createChannel,
deleteChannel,
sendChannelMessage,
getChannels,
} from '../helpers/api';
test.describe('Channel message persistence across delete/re-add', () => {
const suffix = Date.now().toString().slice(-6);
const channelName = `#persist${suffix}`;
let channelKey = '';
test.afterAll(async () => {
// Cleanup
if (channelKey) {
try {
await deleteChannel(channelKey);
} catch {
// Best-effort
}
}
});
test('messages persist after channel delete and re-create', async ({ page }) => {
// Create channel via API
const channel = await createChannel(channelName);
channelKey = channel.key;
// Send a message via API
const testMessage = `persist-test-${Date.now()}`;
await sendChannelMessage(channelKey, testMessage);
// Verify message appears in UI
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText(channelName, { exact: true }).first().click();
await expect(page.getByText(testMessage)).toBeVisible({ timeout: 15_000 });
// Delete channel via API (only removes channels row, messages remain)
await deleteChannel(channelKey);
// Verify channel is gone from sidebar
await page.reload();
await expect(page.getByText('Connected')).toBeVisible();
await expect(page.getByText(channelName, { exact: true })).not.toBeVisible({ timeout: 10_000 });
// Re-create the same hashtag channel (derives same key)
const recreated = await createChannel(channelName);
channelKey = recreated.key;
// Navigate to it
await page.reload();
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText(channelName, { exact: true }).first().click();
// Verify original message is still visible as outgoing
await expect(page.getByText(testMessage)).toBeVisible({ timeout: 15_000 });
});
});

View File

@@ -0,0 +1,99 @@
import { test, expect } from '@playwright/test';
import { syncContacts, getContacts, type Contact } from '../helpers/api';
/** Escape special regex characters in a string. */
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Find a named non-repeater contact (type 2 = repeater). */
function findChatContact(contacts: Contact[]): Contact | undefined {
return contacts.find((c) => c.name && c.name.trim().length > 0 && c.type !== 2);
}
test.describe('Contacts sidebar & info pane', () => {
test.beforeAll(async () => {
await syncContacts();
});
test('contacts appear in sidebar and clicking opens conversation', async ({ page }) => {
const contacts = await getContacts();
const named = findChatContact(contacts);
if (!named) {
test.skip(true, 'No named non-repeater contacts synced from radio');
return;
}
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// Click the contact in the sidebar
await page.getByText(named.name!, { exact: true }).first().click();
// Verify composer placeholder says "Message [name]..."
const escapedName = escapeRegex(named.name!.trim());
await expect(
page.getByPlaceholder(new RegExp(`message\\s+${escapedName}`, 'i'))
).toBeVisible({ timeout: 10_000 });
});
test('contact info pane shows profile data', async ({ page }) => {
const contacts = await getContacts();
const named = findChatContact(contacts);
if (!named) {
test.skip(true, 'No named non-repeater contacts synced from radio');
return;
}
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// Open contact conversation
await page.getByText(named.name!, { exact: true }).first().click();
const escapedName = escapeRegex(named.name!.trim());
await expect(
page.getByPlaceholder(new RegExp(`message\\s+${escapedName}`, 'i'))
).toBeVisible({ timeout: 10_000 });
// Click avatar to open contact info sheet
await page.locator('[title="View contact info"]').click();
// Verify sheet opens with public key text and type badge
// Scope to the Contact Info pane to avoid matching the header pubkey
const infoPane = page.getByLabel('Contact Info');
await expect(infoPane.locator('[title="Click to copy"]')).toBeVisible({ timeout: 10_000 });
await expect(infoPane.getByText(named.public_key.slice(0, 8))).toBeVisible();
});
test('copy public key from contact info pane', async ({ page }) => {
const contacts = await getContacts();
const named = findChatContact(contacts);
if (!named) {
test.skip(true, 'No named non-repeater contacts synced from radio');
return;
}
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText(named.name!, { exact: true }).first().click();
const escapedName = escapeRegex(named.name!.trim());
await expect(
page.getByPlaceholder(new RegExp(`message\\s+${escapedName}`, 'i'))
).toBeVisible({ timeout: 10_000 });
await page.locator('[title="View contact info"]').click();
// Grant clipboard permissions
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
// Click public key to copy (scope to Contact Info pane)
const infoPane = page.getByLabel('Contact Info');
const pubkeySpan = infoPane.locator('[title="Click to copy"]');
await expect(pubkeySpan).toBeVisible({ timeout: 10_000 });
await pubkeySpan.click();
// Verify toast
await expect(page.getByText('Public key copied!')).toBeVisible({ timeout: 5_000 });
});
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
import { deleteContact } from '../helpers/api';
test.describe('Create contact flow', () => {
// A random 64-char hex key for the test contact
const testKey = 'A'.repeat(64);
const testName = `e2econtact${Date.now().toString().slice(-6)}`;
test.afterAll(async () => {
try {
await deleteContact(testKey);
} catch {
// Best-effort cleanup
}
});
test('create a new contact via the new message modal', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// Open new message modal
await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Click "Contact" tab
await dialog.getByRole('tab', { name: /Contact/i }).click();
// Fill in contact name and key
await dialog.locator('#contact-name').fill(testName);
await dialog.locator('#contact-key').fill(testKey);
// Submit
await dialog.getByRole('button', { name: /^Create$/ }).click();
// Verify contact appears (sidebar or header)
await expect(page.getByText(testName, { exact: true }).first()).toBeVisible({ timeout: 10_000 });
});
});

View File

@@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test';
import { deleteChannel, getChannels } from '../helpers/api';
test.describe('Create hashtag channel flow', () => {
const suffix = Date.now().toString().slice(-6);
const channelName1 = `e2echan${suffix}a`;
const channelName2 = `e2echan${suffix}b`;
test.afterAll(async () => {
// Cleanup: delete test channels
const channels = await getChannels();
for (const name of [`#${channelName1}`, `#${channelName2}`]) {
const ch = channels.find((c) => c.name === name);
if (ch) {
try {
await deleteChannel(ch.key);
} catch {
// Best-effort
}
}
}
});
test('create a hashtag channel via UI', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// Open new message modal
await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Click "Hashtag" tab
await dialog.getByRole('tab', { name: /Hashtag/i }).click();
// Fill in channel name
await dialog.locator('#hashtag-name').fill(channelName1);
// Click "Create"
await dialog.getByRole('button', { name: /^Create$/ }).click();
// Verify channel appears (sidebar or header)
await expect(page.getByText(`#${channelName1}`, { exact: true }).first()).toBeVisible({
timeout: 10_000,
});
});
test('create & add another keeps modal open', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('tab', { name: /Hashtag/i }).click();
await dialog.locator('#hashtag-name').fill(channelName2);
// Click "Create & Add Another"
await dialog.getByRole('button', { name: /Create & Add Another/i }).click();
// Dialog should stay open and input should be cleared
await expect(dialog).toBeVisible();
await expect(dialog.locator('#hashtag-name')).toHaveValue('');
// First channel should have been created
// Close dialog and verify
await dialog.getByRole('button', { name: /Cancel/i }).click();
await expect(page.getByText(`#${channelName2}`, { exact: true }).first()).toBeVisible({
timeout: 10_000,
});
});
});

View File

@@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test';
import { deleteChannel, getChannels, getUndecryptedCount } from '../helpers/api';
import { injectEncryptedGroupText } from '../helpers/seed';
test.describe('Historical packet decryption', () => {
const suffix = Date.now().toString().slice(-6);
const channelName = `decrypt${suffix}`;
const messageText = `hello from history ${suffix}`;
test.afterAll(async () => {
// Cleanup: delete the test channel
const channels = await getChannels();
const ch = channels.find((c) => c.name === `#${channelName}`);
if (ch) {
try {
await deleteChannel(ch.key);
} catch {
// Best-effort
}
}
});
test('historical decryption recovers channel message from stored packet', async ({ page }) => {
// Inject an encrypted GROUP_TEXT packet into raw_packets
injectEncryptedGroupText({
channelName,
senderName: 'TestBot',
messageText,
});
// Verify there are undecrypted packets
const { count } = await getUndecryptedCount();
expect(count).toBeGreaterThan(0);
// Open the UI
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// Open new message modal → Hashtag tab
await page.getByTitle('New Message').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('tab', { name: /Hashtag/i }).click();
// Fill channel name
await dialog.locator('#hashtag-name').fill(channelName);
// Check "Try decrypting" checkbox
const tryHistorical = dialog.locator('#try-historical');
// The checkbox may be hidden until undecrypted count loads — wait for label
await expect(dialog.getByText(/Try decrypting.*stored packet/)).toBeVisible({ timeout: 10_000 });
await tryHistorical.check();
// Click Create
await dialog.getByRole('button', { name: /^Create$/ }).click();
// Wait for the decrypted message to appear in the conversation
// Background decryption runs via POST /packets/decrypt/historical
await expect(page.getByText(messageText)).toBeVisible({ timeout: 30_000 });
});
});

View File

@@ -4,7 +4,7 @@ import { createChannel, getChannels, getMessages } from '../helpers/api';
/**
* These tests wait for real incoming messages from the mesh network.
* They require a radio attached and other nodes actively transmitting.
* Timeout is 10 minutes to allow for intermittent traffic.
* Timeout is 3 minutes to allow for intermittent traffic.
*/
const ROOMS = [
@@ -34,9 +34,9 @@ const ROOMS = [
'#vancouver', '#vashon', '#wardriving', '#wormhole', '#yelling', '#zork',
];
// 10 minute timeout for waiting on mesh traffic
// 3 minute timeout for waiting on mesh traffic
test.describe('Incoming mesh messages', () => {
test.setTimeout(600_000);
test.setTimeout(180_000);
test.beforeAll(async () => {
// Ensure all rooms exist — create any that are missing
@@ -86,7 +86,7 @@ test.describe('Incoming mesh messages', () => {
}
}
throw new Error('No new incoming messages yet');
}).toPass({ intervals: [5_000], timeout: 570_000 });
}).toPass({ intervals: [5_000], timeout: 160_000 });
// Navigate to the channel that received a message
console.log(`Received message in ${foundChannel}: "${foundMessageText}"`);
@@ -134,7 +134,7 @@ test.describe('Incoming mesh messages', () => {
}
}
throw new Error('No new incoming messages with path data yet');
}).toPass({ intervals: [5_000], timeout: 570_000 });
}).toPass({ intervals: [5_000], timeout: 160_000 });
console.log(`Found message with path in ${foundChannel}`);

View File

@@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test';
test.describe('Mesh Visualizer page', () => {
test('mesh visualizer page loads', async ({ page }) => {
await page.goto('/#visualizer');
// Verify heading (may appear in sidebar too, use first())
await expect(page.getByText('Mesh Visualizer').first()).toBeVisible({ timeout: 15_000 });
// Verify Three.js canvas element exists (may have 0x0 dimensions in headless mode,
// so check attachment rather than visibility)
await expect(page.locator('canvas[data-engine^="three.js"]').first()).toBeAttached({
timeout: 15_000,
});
});
});

View File

@@ -0,0 +1,14 @@
import { test, expect } from '@playwright/test';
test.describe('Node Map page', () => {
test('node map page loads', async ({ page }) => {
await page.goto('/#map');
// Verify heading (also appears in sidebar, so scope to main)
await expect(page.getByRole('main').getByText('Node Map')).toBeVisible({ timeout: 10_000 });
// Verify legend elements
await expect(page.getByText('<1h')).toBeVisible();
await expect(page.getByText('<1d')).toBeVisible();
});
});

View File

@@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test';
test.describe('Packet Feed page', () => {
test('packet feed page loads and shows header', async ({ page }) => {
await page.goto('/#raw');
await expect(page.getByText('Raw Packet Feed')).toBeVisible({ timeout: 10_000 });
});
test('a packet appears in the raw packet feed', async ({ page }) => {
// This test waits for real RF traffic — needs 180s timeout
test.setTimeout(180_000);
await page.goto('/#raw');
await expect(page.getByText('Raw Packet Feed')).toBeVisible({ timeout: 10_000 });
// Wait for any route-type badge to appear, confirming a packet rendered
const routeBadge = page.locator(
'[title="Flood"], [title="Direct"], [title="Transport Flood"], [title="Transport Direct"]'
);
await expect(routeBadge.first()).toBeVisible({ timeout: 170_000 });
});
});

View File

@@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
import { createChannel, deleteChannel, getChannels } from '../helpers/api';
test.describe('Sidebar search/filter', () => {
const suffix = Date.now().toString().slice(-6);
const nameA = `#alpha${suffix}`;
const nameB = `#bravo${suffix}`;
let keyA = '';
let keyB = '';
test.beforeAll(async () => {
const chA = await createChannel(nameA);
const chB = await createChannel(nameB);
keyA = chA.key;
keyB = chB.key;
});
test.afterAll(async () => {
for (const key of [keyA, keyB]) {
try {
await deleteChannel(key);
} catch {
// Best-effort cleanup
}
}
});
test('search filters conversations by name', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// Both channels should be visible
await expect(page.getByText(nameA, { exact: true })).toBeVisible();
await expect(page.getByText(nameB, { exact: true })).toBeVisible();
// Type partial name to filter
const searchInput = page.getByPlaceholder('Search...');
await searchInput.fill(`alpha${suffix}`);
// Only nameA should be visible
await expect(page.getByText(nameA, { exact: true })).toBeVisible();
await expect(page.getByText(nameB, { exact: true })).not.toBeVisible();
// Clear search
await page.getByTitle('Clear search').click();
// Both should return
await expect(page.getByText(nameA, { exact: true })).toBeVisible();
await expect(page.getByText(nameB, { exact: true })).toBeVisible();
});
});

View File

@@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test';
test.describe('Sidebar sort toggle', () => {
test('toggle sort order between A-Z and recent', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// There are multiple sort toggles (Channels, Contacts, Repeaters sections).
// Use .first() to target the Channels sort toggle.
// When sort is 'alpha', button text is "A-Z" and title is "Sort by recent".
// When sort is 'recent', button text is "⏱" and title is "Sort alphabetically".
const sortByRecent = page.getByTitle('Sort by recent').first();
const sortAlpha = page.getByTitle('Sort alphabetically').first();
// Wait for at least one sort button to appear
await expect(sortByRecent.or(sortAlpha)).toBeVisible({ timeout: 10_000 });
const isAlpha = await sortByRecent.isVisible();
if (isAlpha) {
// Currently A-Z, clicking should switch to recent
await sortByRecent.click();
await expect(sortAlpha).toBeVisible({ timeout: 5_000 });
// Click again to revert
await sortAlpha.click();
await expect(sortByRecent).toBeVisible({ timeout: 5_000 });
} else {
// Currently recent, clicking should switch to A-Z
await sortAlpha.click();
await expect(sortByRecent).toBeVisible({ timeout: 5_000 });
// Click again to revert
await sortByRecent.click();
await expect(sortAlpha).toBeVisible({ timeout: 5_000 });
}
});
});

View File

@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
test.describe('Statistics page', () => {
test('statistics section shows data', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// Open settings
await page.getByText('Settings').click();
// Click the Statistics section
await page.getByRole('button', { name: /Statistics/i }).click();
// Verify section headings/labels are visible (use heading role or exact match to avoid ambiguity)
await expect(page.locator('h4').getByText('Network')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Contacts', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Channels', { exact: true }).first()).toBeVisible();
await expect(page.locator('h4').getByText('Packets')).toBeVisible();
});
});

View File

@@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test';
import { seedChannelUnread } from '../helpers/seed';
const CHANNEL_NAME = '#unread-e2e';
test.describe('Unread badge/pip', () => {
test('unread badge appears for channel with new messages', async ({ page }) => {
// Seed unread messages for the channel
seedChannelUnread({ channelName: CHANNEL_NAME, unreadCount: 3 });
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// Find the channel in the sidebar
const channelRow = page.getByText(CHANNEL_NAME, { exact: true }).first();
await expect(channelRow).toBeVisible({ timeout: 15_000 });
// Verify unread badge (rounded-full pip) is visible within the channel's sidebar row
const sidebarRow = channelRow.locator('xpath=ancestor::div[contains(@class,"cursor-pointer")][1]');
const unreadBadge = sidebarRow.locator('span.rounded-full');
await expect(unreadBadge).toBeVisible();
});
});

View File

@@ -2218,3 +2218,206 @@ class TestHistoricalDMDirectionDetection:
mock_create.assert_awaited_once()
call_kwargs = mock_create.call_args[1]
assert call_kwargs["outgoing"] is False
class TestHistoricalChannelDecryptIntegration:
"""Integration test: store undecrypted packet → add hashtag room → historical decrypt.
Exercises the full flow with real AES encryption (no mocked decryption),
verifying that _run_historical_channel_decryption can recover messages
from raw packets stored before the channel key was known.
"""
@staticmethod
def _build_group_text_packet(
channel_key: bytes, timestamp: int, sender: str, message: str
) -> bytes:
"""Build a complete raw FLOOD/GROUP_TEXT packet with real AES encryption.
Packet layout:
[header:1][path_length:1][payload...]
Header byte for FLOOD(1) + GROUP_TEXT(5) + version 0:
(0 << 6) | (5 << 2) | 1 = 0x15
"""
import hashlib as _hashlib
import hmac as _hmac
from Crypto.Cipher import AES as _AES
# Build plaintext: timestamp(4 LE) + flags(1) + "sender: message\0" + padding
text = f"{sender}: {message}"
plaintext = (
timestamp.to_bytes(4, "little")
+ b"\x00" # flags
+ text.encode("utf-8")
+ b"\x00" # null terminator
)
pad_len = (16 - len(plaintext) % 16) % 16
if pad_len == 0:
pad_len = 16
plaintext += bytes(pad_len)
# AES-128 ECB encrypt
ciphertext = _AES.new(channel_key, _AES.MODE_ECB).encrypt(plaintext)
# MAC: HMAC-SHA256(channel_secret, ciphertext)[:2]
channel_secret = channel_key + bytes(16)
mac = _hmac.new(channel_secret, ciphertext, _hashlib.sha256).digest()[:2]
# channel_hash: first byte of SHA256(key)
channel_hash = _hashlib.sha256(channel_key).digest()[0:1]
# Payload: channel_hash(1) + mac(2) + ciphertext
payload = channel_hash + mac + ciphertext
# Wrap in a FLOOD GROUP_TEXT packet: header=0x15, path_length=0
return bytes([0x15, 0x00]) + payload
@pytest.mark.asyncio
async def test_store_then_add_room_then_historical_decrypt(
self, test_db, captured_broadcasts
):
"""Full flow: packet arrives for unknown channel, channel added later, historical decrypt recovers the message."""
import hashlib as _hashlib
from app.packet_processor import process_raw_packet
from app.routers.packets import _run_historical_channel_decryption
channel_name = "#testroom"
channel_key = _hashlib.sha256(channel_name.encode()).digest()[:16]
channel_key_hex = channel_key.hex().upper()
timestamp = 1700000000
sender = "Alice"
message_text = "Hello from the past"
raw_packet = self._build_group_text_packet(channel_key, timestamp, sender, message_text)
# --- Step 1: packet arrives but channel is unknown → stored undecrypted ---
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
result = await process_raw_packet(raw_packet, timestamp=timestamp)
assert result is not None
# No message broadcast (channel unknown)
message_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert len(message_broadcasts) == 0
# Raw packet is in the undecrypted pool
undecrypted = await RawPacketRepository.get_all_undecrypted()
assert len(undecrypted) == 1
packet_id = undecrypted[0][0]
# --- Step 2: user adds the hashtag room ---
await ChannelRepository.upsert(
key=channel_key_hex, name=channel_name, is_hashtag=True
)
# --- Step 3: run historical channel decryption (real crypto, no mocks) ---
broadcasts.clear()
with patch("app.websocket.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
await _run_historical_channel_decryption(
channel_key, channel_key_hex, channel_name
)
# --- Verify: message was created in DB ---
messages = await MessageRepository.get_all(
msg_type="CHAN", conversation_key=channel_key_hex, limit=10
)
assert len(messages) == 1
msg = messages[0]
assert msg.text == f"{sender}: {message_text}"
assert msg.sender_timestamp == timestamp
assert msg.conversation_key == channel_key_hex
# --- Verify: raw packet is now marked as decrypted ---
undecrypted_after = await RawPacketRepository.get_all_undecrypted()
remaining_ids = [p[0] for p in undecrypted_after]
assert packet_id not in remaining_ids
@pytest.mark.asyncio
async def test_historical_decrypt_skips_wrong_channel(
self, test_db, captured_broadcasts
):
"""Historical decrypt with a different channel key does not decrypt the packet."""
import hashlib as _hashlib
from app.packet_processor import process_raw_packet
from app.routers.packets import _run_historical_channel_decryption
real_key = _hashlib.sha256(b"#real-room").digest()[:16]
wrong_key = _hashlib.sha256(b"#wrong-room").digest()[:16]
wrong_key_hex = wrong_key.hex().upper()
raw_packet = self._build_group_text_packet(real_key, 1700000000, "Bob", "Secret")
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
await process_raw_packet(raw_packet, timestamp=1700000000)
# Packet stored undecrypted
assert len(await RawPacketRepository.get_all_undecrypted()) == 1
# Run historical decrypt with the wrong key
with patch("app.websocket.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
await _run_historical_channel_decryption(wrong_key, wrong_key_hex, "#wrong-room")
# No message created
messages = await MessageRepository.get_all(
msg_type="CHAN", conversation_key=wrong_key_hex, limit=10
)
assert len(messages) == 0
# Packet still undecrypted
assert len(await RawPacketRepository.get_all_undecrypted()) == 1
@pytest.mark.asyncio
async def test_historical_decrypt_multiple_packets(
self, test_db, captured_broadcasts
):
"""Historical decrypt recovers multiple messages from different senders."""
import hashlib as _hashlib
from app.packet_processor import process_raw_packet
from app.routers.packets import _run_historical_channel_decryption
channel_name = "#multi"
channel_key = _hashlib.sha256(channel_name.encode()).digest()[:16]
channel_key_hex = channel_key.hex().upper()
packets = [
self._build_group_text_packet(channel_key, 1700000001, "Alice", "First message"),
self._build_group_text_packet(channel_key, 1700000002, "Bob", "Second message"),
self._build_group_text_packet(channel_key, 1700000003, "Carol", "Third message"),
]
broadcasts, mock_broadcast = captured_broadcasts
# Store all packets (channel unknown)
with patch("app.packet_processor.broadcast_event", mock_broadcast):
for pkt in packets:
await process_raw_packet(pkt, timestamp=1700000000)
assert len(await RawPacketRepository.get_all_undecrypted()) == 3
# Add channel, run historical decrypt
await ChannelRepository.upsert(key=channel_key_hex, name=channel_name, is_hashtag=True)
with patch("app.websocket.ws_manager") as mock_ws:
mock_ws.broadcast = AsyncMock()
await _run_historical_channel_decryption(channel_key, channel_key_hex, channel_name)
messages = await MessageRepository.get_all(
msg_type="CHAN", conversation_key=channel_key_hex, limit=10
)
assert len(messages) == 3
texts = sorted(m.text for m in messages)
assert texts == ["Alice: First message", "Bob: Second message", "Carol: Third message"]
# All packets now decrypted
assert len(await RawPacketRepository.get_all_undecrypted()) == 0