mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-03 12:03:04 +02:00
Expand tests with E2E coverage
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
60
tests/e2e/specs/channel-message-persistence.spec.ts
Normal file
60
tests/e2e/specs/channel-message-persistence.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
99
tests/e2e/specs/contacts.spec.ts
Normal file
99
tests/e2e/specs/contacts.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
39
tests/e2e/specs/create-contact.spec.ts
Normal file
39
tests/e2e/specs/create-contact.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
73
tests/e2e/specs/create-hashtag.spec.ts
Normal file
73
tests/e2e/specs/create-hashtag.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
61
tests/e2e/specs/historical-decryption.spec.ts
Normal file
61
tests/e2e/specs/historical-decryption.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
16
tests/e2e/specs/mesh-visualizer.spec.ts
Normal file
16
tests/e2e/specs/mesh-visualizer.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
14
tests/e2e/specs/node-map.spec.ts
Normal file
14
tests/e2e/specs/node-map.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
23
tests/e2e/specs/packet-feed.spec.ts
Normal file
23
tests/e2e/specs/packet-feed.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
51
tests/e2e/specs/sidebar-search.spec.ts
Normal file
51
tests/e2e/specs/sidebar-search.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
38
tests/e2e/specs/sidebar-sort.spec.ts
Normal file
38
tests/e2e/specs/sidebar-sort.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
20
tests/e2e/specs/statistics.spec.ts
Normal file
20
tests/e2e/specs/statistics.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
23
tests/e2e/specs/unread-indicator.spec.ts
Normal file
23
tests/e2e/specs/unread-indicator.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user