mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test(e2e): openhuman ↔ tiny.place messaging e2e (core + Playwright) (#4624)
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// UI e2e: tiny.place direct messaging through the real openhuman Messaging
|
||||
// screen, against a real tiny.place backend.
|
||||
//
|
||||
// The app-under-test (Alice) talks to the standalone core the web session
|
||||
// booted (PW_CORE_RPC_URL). A second real openhuman-core (Bob) is launched here
|
||||
// as the peer. Both point at the same tiny.place backend
|
||||
// (TINYPLACE_API_BASE_URL). We establish an accepted contact out-of-band (the
|
||||
// contact request/accept flow itself is covered exhaustively by the core suite
|
||||
// at e2e/tinyplace-messaging/messaging.e2e.mjs), then drive the DM UI:
|
||||
//
|
||||
// • type a recipient + open the DM thread
|
||||
// • send an end-to-end encrypted message from the UI → assert the real peer
|
||||
// core receives and decrypts it
|
||||
// • have the peer reply → assert the plaintext renders in the UI thread
|
||||
//
|
||||
// Requires the web session harness (app/scripts/e2e-web-session.sh) with
|
||||
// TINYPLACE_API_BASE_URL exported so the core hits a real backend. The
|
||||
// messaging e2e runner (e2e/tinyplace-messaging/run-ui.sh) wires this up.
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import { bootAuthenticatedPage } from '../helpers/core-rpc';
|
||||
// The core-launch helper is shared with the core-level suite (plain ESM).
|
||||
import { launchAgent, receiveMessage } from '../../../../e2e/tinyplace-messaging/lib/core.mjs';
|
||||
|
||||
const CORE_RPC_URL = process.env.PW_CORE_RPC_URL || 'http://127.0.0.1:17788/rpc';
|
||||
const CORE_RPC_TOKEN = process.env.PW_CORE_RPC_TOKEN || 'openhuman-playwright-token';
|
||||
const BACKEND = process.env.TINYPLACE_API_BASE_URL || 'http://localhost:18080';
|
||||
|
||||
const TEST_MNEMONIC_WORDS = 12;
|
||||
// A fresh, valid BIP-39 mnemonic for Alice (the app's core identity). Generated
|
||||
// via the same dependency-free generator the core suite uses.
|
||||
async function freshMnemonic(): Promise<string> {
|
||||
const { generateMnemonic } = await import('../../../../e2e/tinyplace-messaging/lib/mnemonic.mjs');
|
||||
const m = generateMnemonic();
|
||||
if (m.split(' ').length !== TEST_MNEMONIC_WORDS) throw new Error('unexpected mnemonic length');
|
||||
return m;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_ACCOUNTS = [
|
||||
{ chain: 'evm', address: '0x0000000000000000000000000000000000000001', derivationPath: "m/44'/60'/0'/0/0" },
|
||||
{ chain: 'btc', address: 'bc1qplaceholderplaceholderplaceholderplac0000', derivationPath: "m/84'/0'/0'/0/0" },
|
||||
{ chain: 'solana', address: '11111111111111111111111111111111', derivationPath: "m/44'/501'/0'/0'" },
|
||||
{ chain: 'tron', address: 'T0000000000000000000000000000000001', derivationPath: "m/44'/195'/0'/0/0" },
|
||||
];
|
||||
|
||||
/** Call Alice's (the app's) core over JSON-RPC and unwrap the {logs,result}. */
|
||||
async function aliceRpc<T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const res = await fetch(CORE_RPC_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${CORE_RPC_TOKEN}` },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (body.error) throw new Error(`${method} -> ${JSON.stringify(body.error)}`);
|
||||
let result = body.result;
|
||||
if (result && typeof result === 'object' && 'result' in result && 'logs' in result) {
|
||||
result = result.result;
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
let bob: Awaited<ReturnType<typeof launchAgent>>;
|
||||
let aliceCryptoId: string;
|
||||
|
||||
test.describe('tiny.place direct messaging (UI)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// 1) Give the app's core a fresh tiny.place identity + published Signal keys.
|
||||
const mnemonic = await freshMnemonic();
|
||||
const encryptedMnemonic = await aliceRpc<string>('openhuman.encrypt_secret', { plaintext: mnemonic });
|
||||
await aliceRpc('openhuman.wallet_setup', {
|
||||
consentGranted: true,
|
||||
source: 'imported',
|
||||
mnemonicWordCount: TEST_MNEMONIC_WORDS,
|
||||
encryptedMnemonic,
|
||||
accounts: PLACEHOLDER_ACCOUNTS,
|
||||
force: true,
|
||||
});
|
||||
await aliceRpc('openhuman.tinyplace_signal_provision', { preKeyCount: 10 });
|
||||
await aliceRpc('openhuman.tinyplace_signal_register_encryption_key', {});
|
||||
const status = await aliceRpc<{ agentId: string }>('openhuman.tinyplace_signal_key_status', {});
|
||||
aliceCryptoId = status.agentId;
|
||||
expect(aliceCryptoId, 'app core produced a cryptoId').toBeTruthy();
|
||||
|
||||
// 2) Launch the peer core (Bob) and make Alice + Bob accepted contacts so
|
||||
// the relay will carry their DMs.
|
||||
bob = await launchAgent('pw-bob', { port: 17851, backend: BACKEND });
|
||||
await aliceRpc('openhuman.tinyplace_contacts_request', { agentId: bob.cryptoId });
|
||||
await bob.rpc('openhuman.tinyplace_contacts_accept', { agentId: aliceCryptoId });
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
bob?.stop();
|
||||
});
|
||||
|
||||
test('sends an encrypted DM from the UI that the peer decrypts, and renders the peer reply', async ({ page }) => {
|
||||
await bootAuthenticatedPage(page, 'pw-messaging-user', '/agent-world/messaging');
|
||||
|
||||
// The DM composer: enter the peer's cryptoId and open the thread.
|
||||
const recipient = page.getByPlaceholder('Recipient @handle or wallet address');
|
||||
await expect(recipient).toBeVisible();
|
||||
await recipient.fill(bob.cryptoId);
|
||||
await page.getByRole('button', { name: 'Open DM' }).click();
|
||||
|
||||
// We're in the encrypted thread with Bob (the header lock badge + empty state).
|
||||
await expect(page.getByText('Encrypted', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('dm-empty-state')).toBeVisible();
|
||||
|
||||
// Send an end-to-end encrypted message from the UI.
|
||||
const outgoing = `ui → peer @ ${Date.now()}`;
|
||||
const compose = page.getByPlaceholder('Type a message...');
|
||||
await compose.fill(outgoing);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// Optimistic echo appears in the thread.
|
||||
await expect(page.getByText(outgoing)).toBeVisible();
|
||||
|
||||
// The real peer core receives + decrypts exactly what the UI sent.
|
||||
const received = await receiveMessage(bob, { fromCryptoId: aliceCryptoId, timeoutMs: 12_000 });
|
||||
expect(received, 'peer decrypts the message sent from the UI').toBe(outgoing);
|
||||
|
||||
// Now the peer replies; the plaintext must render in the UI thread.
|
||||
const reply = `peer → ui @ ${Date.now()}`;
|
||||
await bob.rpc('openhuman.tinyplace_signal_send_message', { recipient: aliceCryptoId, plaintext: reply });
|
||||
|
||||
// Wait until the reply envelope is actually in Alice's mailbox — inspected,
|
||||
// not decrypted (decrypt advances the ratchet and can only run once, so we
|
||||
// must not consume it before the UI does).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const list = await aliceRpc<any>('openhuman.tinyplace_messages_list', { limit: 50 });
|
||||
const envelopes = Array.isArray(list) ? list : list?.messages ?? [];
|
||||
return envelopes.some((e: any) => e.from === bob.cryptoId);
|
||||
},
|
||||
{ timeout: 15_000, intervals: [500, 1000] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Re-open the thread once. The DM view has no interval poll; its mount fetch
|
||||
// is what decrypts + renders the delivered reply.
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
await page.getByPlaceholder('Recipient @handle or wallet address').fill(bob.cryptoId);
|
||||
await page.getByRole('button', { name: 'Open DM' }).click();
|
||||
await expect(page.getByText(reply)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
# openhuman ↔ tiny.place messaging e2e
|
||||
|
||||
End-to-end coverage for the tiny.place direct-messaging flow — **DMs, contact
|
||||
requests, accepting requests, and sending messages** — exercised through
|
||||
openhuman's real core against a real tiny.place backend. Two layers:
|
||||
|
||||
| Layer | File | What it drives |
|
||||
| ----- | ---- | -------------- |
|
||||
| **Core** | [`messaging.e2e.mjs`](messaging.e2e.mjs) | Two real `openhuman-core` processes talking to each other over the `openhuman.tinyplace_*` JSON-RPC surface (the exact API the desktop UI calls via `core_rpc_relay`). |
|
||||
| **UI** | [`../../app/test/playwright/specs/tinyplace-messaging.spec.ts`](../../app/test/playwright/specs/tinyplace-messaging.spec.ts) | The web build of the app (Messaging screen) driving the same flow through the browser, with a second core as the peer. |
|
||||
|
||||
Both run against the **real Go backend** (identity/contacts/relay/Signal
|
||||
key services) — not a mock — because messaging is contact-gated and
|
||||
Signal-encrypted server-side, and only the real backend enforces that.
|
||||
|
||||
## What the core suite proves
|
||||
|
||||
Each `openhuman-core` derives its tiny.place identity (a base58 Solana
|
||||
`cryptoId`) from its wallet mnemonic, so two cores with two fresh mnemonics are
|
||||
two distinct agents. The suite walks the full lifecycle:
|
||||
|
||||
1. **Identities** — each core boots a distinct, message-ready identity
|
||||
(published signed pre-key, one-time pre-keys, and directory encryption key).
|
||||
2. **Contact gate** — a DM between non-contacts is refused (`not_a_contact`).
|
||||
3. **Send request** — Alice sends a contact request; Bob sees it `pending`.
|
||||
4. **Accept request** — Bob accepts; both sides see a mutual `accepted` contact.
|
||||
5. **Send DM (X3DH)** — Alice's first message is stored by the relay as opaque
|
||||
ciphertext and decrypts correctly on Bob's side.
|
||||
6. **Reply (Double Ratchet)** — Bob's reply decrypts on Alice's side.
|
||||
7. **In-session DM** — a follow-up message still decrypts.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# Core layer (two openhuman-core processes over JSON-RPC). Brings up an isolated
|
||||
# backend (mongo+redis+backend, static payment verifier) if one isn't already
|
||||
# reachable, builds the core if needed, then runs the node:test suite.
|
||||
./run.sh
|
||||
|
||||
# UI layer (Playwright against the web build). Same backend handling; boots the
|
||||
# app's core + web host and a peer core, then drives the Messaging screen.
|
||||
./run-ui.sh
|
||||
```
|
||||
|
||||
Or, if you already have a backend and a built core:
|
||||
|
||||
```bash
|
||||
TINYPLACE_API_BASE_URL=http://localhost:18080 node --test messaging.e2e.mjs
|
||||
```
|
||||
|
||||
## What the UI suite proves
|
||||
|
||||
Driven through the real **Messaging** screen (`/agent-world/messaging`) of the
|
||||
web build, against the same real backend, with a second core as the peer:
|
||||
|
||||
1. **Send** — typing a recipient + message and hitting Send emits an
|
||||
end-to-end encrypted DM that the real peer core receives and decrypts.
|
||||
2. **Receive** — a reply sent by the peer renders as plaintext in the UI thread.
|
||||
|
||||
Contact establishment is done out-of-band here (it's exhaustively covered by the
|
||||
core suite); the UI layer focuses on the encrypted send/receive round trip a
|
||||
user actually performs on screen.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Docker (only if you want `run.sh` to auto-start the backend).
|
||||
- A built `openhuman-core` binary (`cargo build --bin openhuman-core`; `run.sh`
|
||||
builds it if missing). On Apple Silicon prefix with `GGML_NATIVE=OFF`.
|
||||
|
||||
### Env knobs
|
||||
|
||||
| Var | Default | Meaning |
|
||||
| --- | ------- | ------- |
|
||||
| `TINYPLACE_API_BASE_URL` | `http://localhost:18080` | Backend base URL both cores point at. |
|
||||
| `OPENHUMAN_CORE_BIN` | `target/debug/openhuman-core` | Path to the core binary. |
|
||||
| `MANAGE_STACK` | `1` | `0` disables auto start/stop of the backend. |
|
||||
| `BACKEND_PORT` | `18080` | Host port for the managed backend. |
|
||||
| `VERBOSE` | – | `1` streams each core's stdout/stderr. |
|
||||
|
||||
## Why two cores instead of one core + a mock peer
|
||||
|
||||
The core's tiny.place identity and Signal session state are process-global
|
||||
singletons — one process is exactly one identity. A real two-party round trip
|
||||
therefore needs two processes. Using two real cores (rather than a hand-rolled
|
||||
SDK peer) means **both** ends of every assertion are the actual openhuman code
|
||||
path under test.
|
||||
|
||||
## Notes
|
||||
|
||||
- Every run generates fresh mnemonics (see [`lib/mnemonic.mjs`](lib/mnemonic.mjs))
|
||||
so identities never collide with pre-key state a previous run already
|
||||
published to the backend (which would `409` on re-provision). No npm install
|
||||
is needed — the BIP-39 generator is dependency-free.
|
||||
- The backend must run with a payment verifier that doesn't require real funds
|
||||
for identity provisioning; the umbrella `e2e/docker-compose.e2e.yml` overlay
|
||||
(static verifier) is what `run.sh` uses.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
// Boot a real `openhuman-core` process and drive its tiny.place messaging RPCs.
|
||||
//
|
||||
// openhuman is a Rust core exposing JSON-RPC over `POST /rpc`. All tiny.place
|
||||
// messaging lives in the core's `tinyplace` domain (methods
|
||||
// `openhuman.tinyplace_*`), backed by the vendored Rust tiny.place SDK. The
|
||||
// core derives its tiny.place identity (a base58 Solana cryptoId) from the
|
||||
// wallet mnemonic at m/44'/501'/0'/0'. So: one core process = one identity, and
|
||||
// two cores with two mnemonics = two agents that can message each other over a
|
||||
// real tiny.place backend — exactly what these tests exercise.
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { generateMnemonic } from "./mnemonic.mjs";
|
||||
|
||||
const HERE = resolve(fileURLToPath(import.meta.url), "..");
|
||||
const REPO_ROOT = resolve(HERE, "..", "..", "..");
|
||||
|
||||
export const DEFAULT_CORE_BIN =
|
||||
process.env.OPENHUMAN_CORE_BIN || join(REPO_ROOT, "target", "debug", "openhuman-core");
|
||||
export const DEFAULT_BACKEND =
|
||||
process.env.TINYPLACE_API_BASE_URL || "http://localhost:18080";
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// The tiny.place identity is re-derived from the mnemonic, so these per-chain
|
||||
// account addresses are cosmetic metadata; wallet_setup only validates that
|
||||
// each supported chain appears once with a non-empty address + derivation path.
|
||||
// The Solana derivation path must be the canonical one the signer uses.
|
||||
const PLACEHOLDER_ACCOUNTS = [
|
||||
{ chain: "evm", address: "0x0000000000000000000000000000000000000001", derivationPath: "m/44'/60'/0'/0/0" },
|
||||
{ chain: "btc", address: "bc1qplaceholderplaceholderplaceholderplac0000", derivationPath: "m/84'/0'/0'/0/0" },
|
||||
{ chain: "solana", address: "11111111111111111111111111111111", derivationPath: "m/44'/501'/0'/0'" },
|
||||
{ chain: "tron", address: "T0000000000000000000000000000000001", derivationPath: "m/44'/195'/0'/0/0" },
|
||||
];
|
||||
|
||||
function makeRpc(port, token, label) {
|
||||
return async function rpc(method, params = {}) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`http://127.0.0.1:${port}/rpc`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method, params }),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`[${label}] ${method} transport error: ${e.message}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (body.error) {
|
||||
throw new Error(`[${label}] ${method} -> ${JSON.stringify(body.error)}`);
|
||||
}
|
||||
// The core wraps every handler payload as { logs, result }; unwrap it.
|
||||
let result = body.result;
|
||||
if (result && typeof result === "object" && "result" in result && "logs" in result) {
|
||||
result = result.result;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch an openhuman core, import a fresh wallet identity, publish Signal
|
||||
* pre-keys, and advertise the encryption key on the directory card — i.e. a
|
||||
* fully message-ready tiny.place agent.
|
||||
*
|
||||
* Returns a handle: { name, cryptoId, rpc, port, workspace, stop() }.
|
||||
*/
|
||||
export async function launchAgent(name, opts = {}) {
|
||||
const {
|
||||
port,
|
||||
backend = DEFAULT_BACKEND,
|
||||
coreBin = DEFAULT_CORE_BIN,
|
||||
mnemonic = generateMnemonic(),
|
||||
preKeyCount = 10,
|
||||
verbose = !!process.env.VERBOSE,
|
||||
readyTimeoutMs = 45_000,
|
||||
} = opts;
|
||||
if (!port) throw new Error("launchAgent requires an explicit port");
|
||||
|
||||
const token = `oh-${name}-${process.pid}-token`;
|
||||
const workspace = mkdtempSync(join(tmpdir(), `oh-${name}-`));
|
||||
const child = spawn(
|
||||
coreBin,
|
||||
["run", "--host", "127.0.0.1", "--port", String(port), "--jsonrpc-only"],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
OPENHUMAN_WORKSPACE: workspace,
|
||||
OPENHUMAN_KEYRING_BACKEND: "file",
|
||||
OPENHUMAN_CORE_TOKEN: token,
|
||||
TINYPLACE_API_BASE_URL: backend,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const logs = [];
|
||||
const capture = (stream, tag) =>
|
||||
stream.on("data", (d) => {
|
||||
const line = d.toString();
|
||||
logs.push(line);
|
||||
if (verbose) process.stderr.write(`[${name}${tag}] ${line}`);
|
||||
});
|
||||
capture(child.stdout, "");
|
||||
capture(child.stderr, "!");
|
||||
|
||||
let stopped = false;
|
||||
const stop = () => {
|
||||
if (!stopped) {
|
||||
stopped = true;
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
};
|
||||
const fail = (msg) => {
|
||||
stop();
|
||||
return new Error(`${msg}\n--- last core logs (${name}) ---\n${logs.slice(-15).join("")}`);
|
||||
};
|
||||
|
||||
try {
|
||||
// 1) Wait for the HTTP server to accept connections.
|
||||
let up = false;
|
||||
const deadline = Date.now() + readyTimeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) throw fail(`core '${name}' exited early (code ${child.exitCode})`);
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
if (r.ok) { up = true; break; }
|
||||
} catch { /* not up yet */ }
|
||||
await sleep(400);
|
||||
}
|
||||
if (!up) throw fail(`core '${name}' /health never came up on :${port}`);
|
||||
|
||||
const rpc = makeRpc(port, token, name);
|
||||
|
||||
// 2) Import a fresh wallet identity (encrypt the mnemonic, then persist it).
|
||||
const encryptedMnemonic = await rpc("openhuman.encrypt_secret", { plaintext: mnemonic });
|
||||
await rpc("openhuman.wallet_setup", {
|
||||
consentGranted: true,
|
||||
source: "imported",
|
||||
mnemonicWordCount: mnemonic.split(" ").length,
|
||||
encryptedMnemonic,
|
||||
accounts: PLACEHOLDER_ACCOUNTS,
|
||||
force: true,
|
||||
});
|
||||
|
||||
// 3) Publish Signal pre-keys so peers can start an encrypted session, and
|
||||
// advertise the encryption key on the directory card so it's routable.
|
||||
await rpc("openhuman.tinyplace_signal_provision", { preKeyCount });
|
||||
await rpc("openhuman.tinyplace_signal_register_encryption_key", {});
|
||||
|
||||
const status = await rpc("openhuman.tinyplace_signal_key_status", {});
|
||||
const cryptoId = status.agentId;
|
||||
if (!cryptoId) throw fail(`core '${name}' produced an empty cryptoId`);
|
||||
|
||||
return { name, cryptoId, rpc, port, workspace, stop, mnemonic };
|
||||
} catch (e) {
|
||||
stop();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a core's mailbox, decrypting + acknowledging each envelope, until a
|
||||
* message from `fromCryptoId` (or any, if omitted) is decrypted — or timeout.
|
||||
* Returns the decrypted plaintext string, or null on timeout.
|
||||
*/
|
||||
export async function receiveMessage(core, { fromCryptoId, timeoutMs = 8_000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const list = await core.rpc("openhuman.tinyplace_messages_list", { limit: 50 });
|
||||
const envelopes = Array.isArray(list) ? list : list?.messages ?? [];
|
||||
for (const envelope of envelopes) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = await core.rpc("openhuman.tinyplace_signal_decrypt_message", { envelope });
|
||||
} finally {
|
||||
// Acknowledge (delete) regardless: the ratchet already advanced.
|
||||
await core.rpc("openhuman.tinyplace_messages_acknowledge", { messageId: envelope.id });
|
||||
}
|
||||
if (decoded && (!fromCryptoId || decoded.from === fromCryptoId)) {
|
||||
return decoded.plaintext;
|
||||
}
|
||||
}
|
||||
await sleep(400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Minimal, dependency-free BIP-39 mnemonic generator (English, 128-bit → 12
|
||||
// words). Self-contained so the messaging e2e needs no npm install in this
|
||||
// pnpm-enforced repo. Standard algorithm: 16 random entropy bytes, append the
|
||||
// first ENT/32 = 4 bits of SHA-256(entropy) as checksum, slice into 11-bit
|
||||
// groups, map each to the canonical English wordlist.
|
||||
//
|
||||
// We only need *fresh, valid* mnemonics per run so every test identity is a
|
||||
// brand-new tiny.place cryptoId (avoids colliding with prekey state a previous
|
||||
// run already published to the backend, which would 409 on re-provision).
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const WORDLIST = readFileSync(join(HERE, "bip39-english.txt"), "utf8")
|
||||
.split("\n")
|
||||
.map((w) => w.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (WORDLIST.length !== 2048) {
|
||||
throw new Error(`bip39 wordlist must have 2048 words, got ${WORDLIST.length}`);
|
||||
}
|
||||
|
||||
/** Generate a fresh, checksum-valid 12-word English BIP-39 mnemonic. */
|
||||
export function generateMnemonic() {
|
||||
const entropy = randomBytes(16); // 128 bits
|
||||
const checksum = createHash("sha256").update(entropy).digest();
|
||||
|
||||
// Build a big-endian bit string of entropy (128) + checksum (4) = 132 bits.
|
||||
let bits = "";
|
||||
for (const byte of entropy) bits += byte.toString(2).padStart(8, "0");
|
||||
bits += checksum[0].toString(2).padStart(8, "0").slice(0, 4);
|
||||
|
||||
const words = [];
|
||||
for (let i = 0; i < bits.length; i += 11) {
|
||||
words.push(WORDLIST[parseInt(bits.slice(i, i + 11), 2)]);
|
||||
}
|
||||
return words.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// End-to-end: openhuman ↔ tiny.place messaging, driven through the real
|
||||
// openhuman core JSON-RPC surface against a real tiny.place backend.
|
||||
//
|
||||
// Two independent `openhuman-core` processes (Alice, Bob), each with its own
|
||||
// freshly-imported wallet identity, exercise the complete DM lifecycle exactly
|
||||
// as the desktop app would drive it over `core_rpc_relay`:
|
||||
//
|
||||
// • send a contact request (openhuman.tinyplace_contacts_request)
|
||||
// • see it arrive on the other side (openhuman.tinyplace_contacts_requests)
|
||||
// • accept the request (openhuman.tinyplace_contacts_accept)
|
||||
// • confirm the mutual contact (openhuman.tinyplace_contacts_list)
|
||||
// • send Signal-encrypted DMs (openhuman.tinyplace_signal_send_message)
|
||||
// • receive + decrypt + acknowledge (messages_list / signal_decrypt_message / messages_acknowledge)
|
||||
//
|
||||
// It also proves the two security invariants that make this a real messaging
|
||||
// system: DMs are refused between non-contacts, and the relay only ever stores
|
||||
// opaque ciphertext.
|
||||
//
|
||||
// Requires: a reachable tiny.place backend (TINYPLACE_API_BASE_URL, default
|
||||
// http://localhost:18080) and a built `openhuman-core` binary. `run.sh` wires
|
||||
// both up; see README.md.
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import net from "node:net";
|
||||
|
||||
import { launchAgent, receiveMessage, DEFAULT_BACKEND, DEFAULT_CORE_BIN } from "./lib/core.mjs";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
function freePort() {
|
||||
return new Promise((res, rej) => {
|
||||
const srv = net.createServer();
|
||||
srv.on("error", rej);
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const { port } = srv.address();
|
||||
srv.close(() => res(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function backendReachable() {
|
||||
try {
|
||||
const r = await fetch(`${DEFAULT_BACKEND}/healthz`);
|
||||
return r.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let alice;
|
||||
let bob;
|
||||
|
||||
before(async () => {
|
||||
assert.ok(
|
||||
existsSync(DEFAULT_CORE_BIN),
|
||||
`openhuman-core binary not found at ${DEFAULT_CORE_BIN}. Build it: cargo build --bin openhuman-core (or run run.sh).`,
|
||||
);
|
||||
assert.ok(
|
||||
await backendReachable(),
|
||||
`tiny.place backend not reachable at ${DEFAULT_BACKEND}/healthz. Start it first (see run.sh / README.md).`,
|
||||
);
|
||||
const [pa, pb] = await Promise.all([freePort(), freePort()]);
|
||||
[alice, bob] = await Promise.all([
|
||||
launchAgent("alice", { port: pa }),
|
||||
launchAgent("bob", { port: pb }),
|
||||
]);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
alice?.stop();
|
||||
bob?.stop();
|
||||
});
|
||||
|
||||
test("each core boots a distinct, message-ready tiny.place identity", async () => {
|
||||
assert.match(alice.cryptoId, /^[1-9A-HJ-NP-Za-km-z]{32,44}$/, "alice cryptoId is base58");
|
||||
assert.match(bob.cryptoId, /^[1-9A-HJ-NP-Za-km-z]{32,44}$/, "bob cryptoId is base58");
|
||||
assert.notEqual(alice.cryptoId, bob.cryptoId, "the two cores have different identities");
|
||||
|
||||
for (const core of [alice, bob]) {
|
||||
const status = await core.rpc("openhuman.tinyplace_signal_key_status", {});
|
||||
assert.equal(status.agentId, core.cryptoId);
|
||||
assert.equal(status.hasActiveSignedPreKey, true, `${core.name} published a signed pre-key`);
|
||||
assert.ok(status.localPreKeyCount > 0, `${core.name} holds local one-time pre-keys`);
|
||||
assert.equal(status.encryptionKeyPublished, true, `${core.name} advertised its encryption key`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a DM is refused between agents who are not contacts", async () => {
|
||||
// Fresh, unrelated identity → not a contact of Bob → the relay must refuse.
|
||||
const stranger = await launchAgent("stranger", { port: await freePort() });
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
stranger.rpc("openhuman.tinyplace_signal_send_message", {
|
||||
recipient: bob.cryptoId,
|
||||
plaintext: "you don't know me",
|
||||
}),
|
||||
/not_a_contact|403|contact/i,
|
||||
"sending a DM without an accepted contact relationship is rejected",
|
||||
);
|
||||
} finally {
|
||||
stranger.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("Alice sends a contact request and Bob sees it pending", async () => {
|
||||
await alice.rpc("openhuman.tinyplace_contacts_request", { agentId: bob.cryptoId });
|
||||
|
||||
const incoming = await bob.rpc("openhuman.tinyplace_contacts_requests", {});
|
||||
const ids = (incoming.incoming ?? []).map((r) => r.agentId ?? r.cryptoId);
|
||||
assert.ok(ids.includes(alice.cryptoId), "Alice's request is in Bob's incoming requests");
|
||||
|
||||
const status = await bob.rpc("openhuman.tinyplace_contacts_status", { agentId: alice.cryptoId });
|
||||
assert.equal(status.status, "pending", "relationship is pending before acceptance");
|
||||
});
|
||||
|
||||
test("Bob accepts and both sides see a mutual accepted contact", async () => {
|
||||
await bob.rpc("openhuman.tinyplace_contacts_accept", { agentId: alice.cryptoId });
|
||||
|
||||
const aliceContacts = await alice.rpc("openhuman.tinyplace_contacts_list", {});
|
||||
const aliceIds = (aliceContacts.contacts ?? []).map((c) => c.agentId ?? c.cryptoId);
|
||||
assert.ok(aliceIds.includes(bob.cryptoId), "Bob is in Alice's contact list");
|
||||
|
||||
const bobStatus = await bob.rpc("openhuman.tinyplace_contacts_status", { agentId: alice.cryptoId });
|
||||
assert.equal(bobStatus.status, "accepted", "relationship is accepted");
|
||||
});
|
||||
|
||||
test("Alice → Bob: first DM (X3DH) is delivered as ciphertext and decrypts", async () => {
|
||||
const plaintext = "hello bob — first encrypted DM from alice";
|
||||
const sent = await alice.rpc("openhuman.tinyplace_signal_send_message", {
|
||||
recipient: bob.cryptoId,
|
||||
plaintext,
|
||||
});
|
||||
assert.equal(sent.encrypted, true, "send reports the message was encrypted");
|
||||
assert.ok(sent.messageId, "send returns a messageId");
|
||||
|
||||
// Inspect the raw relayed envelope before decrypting: the relay must only
|
||||
// ever hold opaque ciphertext, never the plaintext.
|
||||
let sawCiphertext = false;
|
||||
for (let i = 0; i < 15 && !sawCiphertext; i++) {
|
||||
const list = await bob.rpc("openhuman.tinyplace_messages_list", { limit: 50 });
|
||||
const envelopes = Array.isArray(list) ? list : list?.messages ?? [];
|
||||
const env = envelopes.find((e) => e.id === sent.messageId);
|
||||
if (env) {
|
||||
assert.notEqual(env.body, plaintext, "relay envelope body is not the plaintext");
|
||||
assert.ok(!String(env.body ?? "").includes(plaintext), "ciphertext does not contain the plaintext");
|
||||
assert.match(env.type, /PREKEY_BUNDLE|CIPHERTEXT/, "envelope carries a Signal message type");
|
||||
sawCiphertext = true;
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
}
|
||||
}
|
||||
assert.ok(sawCiphertext, "Bob's relay mailbox received the ciphertext envelope");
|
||||
|
||||
const received = await receiveMessage(bob, { fromCryptoId: alice.cryptoId });
|
||||
assert.equal(received, plaintext, "Bob decrypts Alice's first message");
|
||||
});
|
||||
|
||||
test("Bob → Alice: ratchet reply decrypts, proving a bidirectional session", async () => {
|
||||
const plaintext = "hi alice — ratchet reply from bob";
|
||||
const sent = await bob.rpc("openhuman.tinyplace_signal_send_message", {
|
||||
recipient: alice.cryptoId,
|
||||
plaintext,
|
||||
});
|
||||
assert.equal(sent.encrypted, true);
|
||||
|
||||
const received = await receiveMessage(alice, { fromCryptoId: bob.cryptoId });
|
||||
assert.equal(received, plaintext, "Alice decrypts Bob's reply");
|
||||
});
|
||||
|
||||
test("a follow-up DM in the established session still decrypts", async () => {
|
||||
const plaintext = "and one more, to prove the session persists";
|
||||
await alice.rpc("openhuman.tinyplace_signal_send_message", {
|
||||
recipient: bob.cryptoId,
|
||||
plaintext,
|
||||
});
|
||||
const received = await receiveMessage(bob, { fromCryptoId: alice.cryptoId });
|
||||
assert.equal(received, plaintext, "Bob decrypts a subsequent in-session message");
|
||||
});
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run the openhuman ↔ tiny.place messaging UI e2e (Playwright).
|
||||
#
|
||||
# Reuses the app's standard web-session harness (app/scripts/e2e-web-session.sh)
|
||||
# — mock cloud backend + standalone core + static web host — but points the
|
||||
# core's *tiny.place* backend at a real one via TINYPLACE_API_BASE_URL, so the
|
||||
# Messaging screen exchanges real Signal DMs. The peer core is launched by the
|
||||
# spec itself.
|
||||
#
|
||||
# Steps (each skipped if already satisfied):
|
||||
# 1. Ensure a tiny.place backend is reachable (auto-start one unless MANAGE_STACK=0).
|
||||
# 2. Ensure the web bundle + core binary are built.
|
||||
# 3. Run the Playwright spec through the web session.
|
||||
#
|
||||
# Env knobs: same as run.sh, plus:
|
||||
# HEADED=1 run Playwright headed.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OPENHUMAN_ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
UMBRELLA_ROOT="$(cd "$OPENHUMAN_ROOT/.." && pwd)"
|
||||
APP_DIR="$OPENHUMAN_ROOT/app"
|
||||
|
||||
BACKEND_PORT="${BACKEND_PORT:-18080}"
|
||||
export TINYPLACE_API_BASE_URL="${TINYPLACE_API_BASE_URL:-http://localhost:${BACKEND_PORT}}"
|
||||
# Reuse the already-built debug core instead of a separate e2e-web target.
|
||||
export E2E_WEB_CORE_TARGET_DIR="${E2E_WEB_CORE_TARGET_DIR:-$OPENHUMAN_ROOT/target}"
|
||||
MANAGE_STACK="${MANAGE_STACK:-1}"
|
||||
COMPOSE_PROJECT="tinyplace-ohe2e"
|
||||
SPEC="test/playwright/specs/tinyplace-messaging.spec.ts"
|
||||
|
||||
log() { printf '\033[1;35m[messaging-ui-e2e]\033[0m %s\n' "$*"; }
|
||||
backend_up() { curl -fsS "${TINYPLACE_API_BASE_URL%/}/healthz" >/dev/null 2>&1; }
|
||||
|
||||
STARTED_STACK=0
|
||||
cleanup() {
|
||||
if [ "$STARTED_STACK" = "1" ]; then
|
||||
log "tearing down backend stack ($COMPOSE_PROJECT)"
|
||||
( cd "$UMBRELLA_ROOT" && MONGO_PORT="${MONGO_PORT:-37017}" REDIS_PORT="${REDIS_PORT:-16379}" BACKEND_PORT="$BACKEND_PORT" \
|
||||
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.yml -f e2e/docker-compose.e2e.yml down -v >/dev/null 2>&1 || true )
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# 1) Backend
|
||||
if backend_up; then
|
||||
log "backend reachable at $TINYPLACE_API_BASE_URL"
|
||||
elif [ "$MANAGE_STACK" != "0" ]; then
|
||||
log "starting isolated backend stack (mongo+redis+backend, static verifier) on :$BACKEND_PORT"
|
||||
( cd "$UMBRELLA_ROOT" && MONGO_PORT="${MONGO_PORT:-37017}" REDIS_PORT="${REDIS_PORT:-16379}" BACKEND_PORT="$BACKEND_PORT" \
|
||||
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.yml -f e2e/docker-compose.e2e.yml up --build -d mongo redis backend )
|
||||
STARTED_STACK=1
|
||||
for i in $(seq 1 60); do backend_up && break; sleep 2; done
|
||||
backend_up || { log "ERROR: backend never became healthy"; exit 1; }
|
||||
log "backend healthy"
|
||||
else
|
||||
log "ERROR: backend not reachable and MANAGE_STACK=0."; exit 1
|
||||
fi
|
||||
|
||||
# 2) Build web bundle + core if needed.
|
||||
if [ ! -f "$APP_DIR/dist-web/index.html" ] || [ ! -x "$E2E_WEB_CORE_TARGET_DIR/debug/openhuman-core" ]; then
|
||||
log "building web e2e bundle (+ core if missing)…"
|
||||
( cd "$OPENHUMAN_ROOT" && bash app/scripts/e2e-web-build.sh )
|
||||
fi
|
||||
# Ensure the Playwright browser is present.
|
||||
( cd "$APP_DIR" && pnpm exec playwright install chromium chromium-headless-shell >/dev/null 2>&1 || true )
|
||||
|
||||
# 3) Run the spec through the web session harness.
|
||||
log "running Playwright spec: $SPEC (tiny.place backend: $TINYPLACE_API_BASE_URL)"
|
||||
ARGS=("$SPEC")
|
||||
[ "${HEADED:-}" = "1" ] && ARGS+=("--headed")
|
||||
exec bash "$APP_DIR/scripts/e2e-web-session.sh" "${ARGS[@]}"
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run the openhuman ↔ tiny.place messaging core e2e.
|
||||
#
|
||||
# Steps (each is skipped if already satisfied):
|
||||
# 1. Ensure a tiny.place backend is reachable at $TINYPLACE_API_BASE_URL.
|
||||
# If not, and MANAGE_STACK != 0, bring one up from the umbrella
|
||||
# docker-compose (mongo + redis + backend, static payment verifier) on an
|
||||
# isolated compose project + ports, and tear it down on exit.
|
||||
# 2. Ensure the openhuman-core binary is built.
|
||||
# 3. Run the node:test suite.
|
||||
#
|
||||
# Env knobs:
|
||||
# TINYPLACE_API_BASE_URL backend base URL (default http://localhost:18080)
|
||||
# OPENHUMAN_CORE_BIN path to openhuman-core (default target/debug/openhuman-core)
|
||||
# MANAGE_STACK 1 = auto-manage backend (default 1)
|
||||
# BACKEND_PORT host port for managed backend (default 18080)
|
||||
# VERBOSE 1 = stream core logs
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OPENHUMAN_ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
UMBRELLA_ROOT="$(cd "$OPENHUMAN_ROOT/.." && pwd)"
|
||||
|
||||
BACKEND_PORT="${BACKEND_PORT:-18080}"
|
||||
export TINYPLACE_API_BASE_URL="${TINYPLACE_API_BASE_URL:-http://localhost:${BACKEND_PORT}}"
|
||||
export OPENHUMAN_CORE_BIN="${OPENHUMAN_CORE_BIN:-$OPENHUMAN_ROOT/target/debug/openhuman-core}"
|
||||
MANAGE_STACK="${MANAGE_STACK:-1}"
|
||||
COMPOSE_PROJECT="tinyplace-ohe2e"
|
||||
|
||||
log() { printf '\033[1;36m[messaging-e2e]\033[0m %s\n' "$*"; }
|
||||
|
||||
backend_up() { curl -fsS "${TINYPLACE_API_BASE_URL%/}/healthz" >/dev/null 2>&1; }
|
||||
|
||||
STARTED_STACK=0
|
||||
cleanup() {
|
||||
if [ "$STARTED_STACK" = "1" ]; then
|
||||
log "tearing down backend stack ($COMPOSE_PROJECT)"
|
||||
( cd "$UMBRELLA_ROOT" && MONGO_PORT="${MONGO_PORT:-37017}" REDIS_PORT="${REDIS_PORT:-16379}" BACKEND_PORT="$BACKEND_PORT" \
|
||||
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.yml -f e2e/docker-compose.e2e.yml down -v >/dev/null 2>&1 || true )
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# 1) Backend
|
||||
if backend_up; then
|
||||
log "backend reachable at $TINYPLACE_API_BASE_URL"
|
||||
elif [ "$MANAGE_STACK" != "0" ]; then
|
||||
if [ ! -f "$UMBRELLA_ROOT/docker-compose.yml" ] || [ ! -f "$UMBRELLA_ROOT/e2e/docker-compose.e2e.yml" ]; then
|
||||
log "ERROR: cannot find umbrella docker-compose to auto-start the backend."
|
||||
log "Start a tiny.place backend yourself and set TINYPLACE_API_BASE_URL."
|
||||
exit 1
|
||||
fi
|
||||
log "starting isolated backend stack (mongo+redis+backend, static verifier) on :$BACKEND_PORT"
|
||||
( cd "$UMBRELLA_ROOT" && MONGO_PORT="${MONGO_PORT:-37017}" REDIS_PORT="${REDIS_PORT:-16379}" BACKEND_PORT="$BACKEND_PORT" \
|
||||
docker compose -p "$COMPOSE_PROJECT" -f docker-compose.yml -f e2e/docker-compose.e2e.yml up --build -d mongo redis backend )
|
||||
STARTED_STACK=1
|
||||
log "waiting for backend health…"
|
||||
for i in $(seq 1 60); do backend_up && break; sleep 2; done
|
||||
backend_up || { log "ERROR: backend never became healthy"; exit 1; }
|
||||
log "backend healthy"
|
||||
else
|
||||
log "ERROR: backend not reachable and MANAGE_STACK=0."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2) Core binary
|
||||
if [ ! -x "$OPENHUMAN_CORE_BIN" ]; then
|
||||
log "building openhuman-core (this can take a while the first time)…"
|
||||
( cd "$OPENHUMAN_ROOT" && GGML_NATIVE=OFF cargo build --bin openhuman-core --manifest-path Cargo.toml )
|
||||
fi
|
||||
log "using core binary: $OPENHUMAN_CORE_BIN"
|
||||
|
||||
# 3) Test
|
||||
log "running node:test suite…"
|
||||
cd "$HERE"
|
||||
exec node --test messaging.e2e.mjs
|
||||
Reference in New Issue
Block a user