diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index 14840ba..8e480e6 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -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; @@ -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 { + return fetchJson('/packets/decrypt/historical', { + method: 'POST', + body: JSON.stringify(params), + }); +} diff --git a/tests/e2e/helpers/seed.ts b/tests/e2e/helpers/seed.ts index 43a3285..c6b6e46 100644 --- a/tests/e2e/helpers/seed.ts +++ b/tests/e2e/helpers/seed.ts @@ -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}` diff --git a/tests/e2e/specs/channel-message-persistence.spec.ts b/tests/e2e/specs/channel-message-persistence.spec.ts new file mode 100644 index 0000000..3f9bf8b --- /dev/null +++ b/tests/e2e/specs/channel-message-persistence.spec.ts @@ -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 }); + }); +}); diff --git a/tests/e2e/specs/contacts.spec.ts b/tests/e2e/specs/contacts.spec.ts new file mode 100644 index 0000000..323f7ab --- /dev/null +++ b/tests/e2e/specs/contacts.spec.ts @@ -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 }); + }); +}); diff --git a/tests/e2e/specs/create-contact.spec.ts b/tests/e2e/specs/create-contact.spec.ts new file mode 100644 index 0000000..bca96f6 --- /dev/null +++ b/tests/e2e/specs/create-contact.spec.ts @@ -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 }); + }); +}); diff --git a/tests/e2e/specs/create-hashtag.spec.ts b/tests/e2e/specs/create-hashtag.spec.ts new file mode 100644 index 0000000..22d2f98 --- /dev/null +++ b/tests/e2e/specs/create-hashtag.spec.ts @@ -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, + }); + }); +}); diff --git a/tests/e2e/specs/historical-decryption.spec.ts b/tests/e2e/specs/historical-decryption.spec.ts new file mode 100644 index 0000000..b9f1678 --- /dev/null +++ b/tests/e2e/specs/historical-decryption.spec.ts @@ -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 }); + }); +}); diff --git a/tests/e2e/specs/incoming-message.spec.ts b/tests/e2e/specs/incoming-message.spec.ts index e0e7547..7719fae 100644 --- a/tests/e2e/specs/incoming-message.spec.ts +++ b/tests/e2e/specs/incoming-message.spec.ts @@ -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}`); diff --git a/tests/e2e/specs/mesh-visualizer.spec.ts b/tests/e2e/specs/mesh-visualizer.spec.ts new file mode 100644 index 0000000..acc7d6a --- /dev/null +++ b/tests/e2e/specs/mesh-visualizer.spec.ts @@ -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, + }); + }); +}); diff --git a/tests/e2e/specs/node-map.spec.ts b/tests/e2e/specs/node-map.spec.ts new file mode 100644 index 0000000..d6d850c --- /dev/null +++ b/tests/e2e/specs/node-map.spec.ts @@ -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(); + }); +}); diff --git a/tests/e2e/specs/packet-feed.spec.ts b/tests/e2e/specs/packet-feed.spec.ts new file mode 100644 index 0000000..6dca2a7 --- /dev/null +++ b/tests/e2e/specs/packet-feed.spec.ts @@ -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 }); + }); +}); diff --git a/tests/e2e/specs/sidebar-search.spec.ts b/tests/e2e/specs/sidebar-search.spec.ts new file mode 100644 index 0000000..5d49427 --- /dev/null +++ b/tests/e2e/specs/sidebar-search.spec.ts @@ -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(); + }); +}); diff --git a/tests/e2e/specs/sidebar-sort.spec.ts b/tests/e2e/specs/sidebar-sort.spec.ts new file mode 100644 index 0000000..8d2c79b --- /dev/null +++ b/tests/e2e/specs/sidebar-sort.spec.ts @@ -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 }); + } + }); +}); diff --git a/tests/e2e/specs/statistics.spec.ts b/tests/e2e/specs/statistics.spec.ts new file mode 100644 index 0000000..230143d --- /dev/null +++ b/tests/e2e/specs/statistics.spec.ts @@ -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(); + }); +}); diff --git a/tests/e2e/specs/unread-indicator.spec.ts b/tests/e2e/specs/unread-indicator.spec.ts new file mode 100644 index 0000000..9f244b5 --- /dev/null +++ b/tests/e2e/specs/unread-indicator.spec.ts @@ -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(); + }); +}); diff --git a/tests/test_packet_pipeline.py b/tests/test_packet_pipeline.py index 18df349..0344581 100644 --- a/tests/test_packet_pipeline.py +++ b/tests/test_packet_pipeline.py @@ -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