Relay pairing: prove direct + iOS deeplink smoke
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
appId: com.moboudra.paseo
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Open in.*Paseo.*\\?"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Open"
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: "Open"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Development servers"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
|
||||||
|
optional: true
|
||||||
|
- tapOn:
|
||||||
|
text: "Enter URL manually"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Continue"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Continue"
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Go home"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,20%"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Online.*"
|
||||||
|
timeout: 90000
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# NOTE: Maestro requires a config section for every flow file, even when used
|
||||||
|
# via `runFlow`. Keep this aligned with the dev build application id.
|
||||||
|
appId: com.moboudra.paseo
|
||||||
|
---
|
||||||
|
# Reusable flow to handle Expo dev client screens after launch.
|
||||||
|
# Unlike `flows/launch.yaml`, this does not assert any particular app route.
|
||||||
|
|
||||||
|
# Handle dev launcher - tap localhost server (dev builds only)
|
||||||
|
- tapOn:
|
||||||
|
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
# Dismiss dev menu intro modal
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Continue"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Continue"
|
||||||
|
|
||||||
|
# Dismiss dev menu if it opens
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Go home"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,20%"
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# NOTE: Maestro requires a config section for every flow file, even when used
|
# NOTE: Maestro requires a config section for every flow file, even when used
|
||||||
# via `runFlow`. Keep this aligned with the dev build application id.
|
# via `runFlow`. Keep this aligned with the dev build application id.
|
||||||
appId: com.moboudra.paseo.dev
|
appId: com.moboudra.paseo
|
||||||
---
|
---
|
||||||
# Reusable flow to handle dev client screens after launchApp
|
# Reusable flow to handle dev client screens after launchApp
|
||||||
# Works with both release builds (no-ops) and dev builds
|
# Works with both release builds (no-ops) and dev builds
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
appId: com.moboudra.paseo
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- runFlow: flows/dev-client.yaml
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
appId: com.moboudra.paseo.dev
|
appId: com.moboudra.paseo
|
||||||
---
|
---
|
||||||
- launchApp:
|
- launchApp:
|
||||||
clearState: true
|
clearState: true
|
||||||
@@ -6,13 +6,13 @@ appId: com.moboudra.paseo.dev
|
|||||||
|
|
||||||
- tapOn: "Paste pairing link"
|
- tapOn: "Paste pairing link"
|
||||||
|
|
||||||
- tapOn:
|
|
||||||
text: "https://app.paseo.sh/#offer=..."
|
|
||||||
- eraseText
|
- eraseText
|
||||||
- inputText: ${PAIRING_OFFER_URL}
|
- inputText: ${PAIRING_OFFER_URL}
|
||||||
- tapOn: "Pair"
|
- tapOn: "Pair"
|
||||||
|
|
||||||
- assertVisible: "New Agent"
|
- extendedWaitUntil:
|
||||||
|
visible: "New agent"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
# Confirm we actually connected (relay is the only available connection).
|
# Confirm we actually connected (relay is the only available connection).
|
||||||
- assertVisible: "Online"
|
- assertVisible: "Online"
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
appId: com.moboudra.paseo.dev
|
appId: com.moboudra.paseo
|
||||||
---
|
---
|
||||||
- launchApp:
|
- launchApp:
|
||||||
clearState: true
|
clearState: true
|
||||||
- runFlow: flows/launch.yaml
|
- runFlow: flows/launch.yaml
|
||||||
|
|
||||||
- tapOn: "Paste pairing link"
|
- tapOn: "Paste pairing link"
|
||||||
- tapOn:
|
|
||||||
text: "https://app.paseo.sh/#offer=..."
|
|
||||||
- eraseText
|
- eraseText
|
||||||
- inputText: ${PAIRING_OFFER_URL}
|
- inputText: ${PAIRING_OFFER_URL}
|
||||||
- tapOn: "Pair"
|
- tapOn:
|
||||||
|
point: "75%,36%"
|
||||||
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "New agent"
|
visible: "New agent"
|
||||||
|
|||||||
@@ -297,15 +297,24 @@ function OfferLinkListener({
|
|||||||
}: {
|
}: {
|
||||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
|
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const handleUrl = (url: string | null) => {
|
const handleUrl = (url: string | null) => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
if (!url.includes("#offer=")) return;
|
if (!url.includes("#offer=")) return;
|
||||||
void upsertDaemonFromOfferUrl(url).catch((error) => {
|
void upsertDaemonFromOfferUrl(url)
|
||||||
if (cancelled) return;
|
.then((profile) => {
|
||||||
console.warn("[Linking] Failed to import pairing offer", error);
|
if (cancelled) return;
|
||||||
});
|
const serverId = (profile as any)?.serverId;
|
||||||
|
if (typeof serverId !== "string" || !serverId) return;
|
||||||
|
router.replace({ pathname: "/", params: { serverId } });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.warn("[Linking] Failed to import pairing offer", error);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
|
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
|
||||||
@@ -318,7 +327,7 @@ function OfferLinkListener({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, [upsertDaemonFromOfferUrl]);
|
}, [router, upsertDaemonFromOfferUrl]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,11 +124,14 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
|||||||
<Text style={styles.label}>Pairing link</Text>
|
<Text style={styles.label}>Pairing link</Text>
|
||||||
<InputComponent
|
<InputComponent
|
||||||
testID="pair-link-input"
|
testID="pair-link-input"
|
||||||
|
nativeID="pair-link-input"
|
||||||
|
accessibilityLabel="pair-link-input"
|
||||||
value={offerUrl}
|
value={offerUrl}
|
||||||
onChangeText={setOfferUrl}
|
onChangeText={setOfferUrl}
|
||||||
placeholder="https://app.paseo.sh/#offer=..."
|
placeholder="https://app.paseo.sh/#offer=..."
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
|
autoFocus
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
@@ -137,7 +140,15 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving} testID="pair-link-cancel">
|
<Pressable
|
||||||
|
style={styles.button}
|
||||||
|
onPress={handleClose}
|
||||||
|
disabled={isSaving}
|
||||||
|
testID="pair-link-cancel"
|
||||||
|
accessible
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Cancel"
|
||||||
|
>
|
||||||
<Text style={styles.buttonText}>Cancel</Text>
|
<Text style={styles.buttonText}>Cancel</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -145,6 +156,9 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
|
|||||||
onPress={() => void handleSave()}
|
onPress={() => void handleSave()}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
testID="pair-link-submit"
|
testID="pair-link-submit"
|
||||||
|
accessible
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Pair"
|
||||||
>
|
>
|
||||||
<View style={styles.connectRow}>
|
<View style={styles.connectRow}>
|
||||||
<Link size={16} color={theme.colors.palette.white} />
|
<Link size={16} color={theme.colors.palette.white} />
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ describe("crypto", () => {
|
|||||||
const daemonKeyPair = await generateKeyPair();
|
const daemonKeyPair = await generateKeyPair();
|
||||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||||
|
|
||||||
// QR code would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] }
|
// QR code would contain: { serverId, daemonPubKeyB64, relay: { endpoint } }
|
||||||
|
|
||||||
// === CLIENT SIDE (scans QR) ===
|
// === CLIENT SIDE (scans QR) ===
|
||||||
const clientKeyPair = await generateKeyPair();
|
const clientKeyPair = await generateKeyPair();
|
||||||
|
|||||||
@@ -25,18 +25,18 @@ describe("E2E Relay with E2EE", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("full flow: daemon and client exchange encrypted messages through relay", async () => {
|
it("full flow: daemon and client exchange encrypted messages through relay", async () => {
|
||||||
const sessionId = "test-session-" + Date.now();
|
const serverId = "test-session-" + Date.now();
|
||||||
|
|
||||||
// === DAEMON SIDE ===
|
// === DAEMON SIDE ===
|
||||||
// Generate keypair (public key goes in QR)
|
// Generate keypair (public key goes in QR)
|
||||||
const daemonKeyPair = await generateKeyPair();
|
const daemonKeyPair = await generateKeyPair();
|
||||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||||
|
|
||||||
// QR would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] }
|
// QR would contain: { serverId, daemonPubKeyB64, relay: { endpoint } }
|
||||||
|
|
||||||
// Daemon connects to relay as "server" role
|
// Daemon connects to relay as "server" role
|
||||||
const daemonWs = new WebSocket(
|
const daemonWs = new WebSocket(
|
||||||
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=server`
|
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=server`
|
||||||
);
|
);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -59,7 +59,7 @@ describe("E2E Relay with E2EE", () => {
|
|||||||
|
|
||||||
// Client connects to relay as "client" role
|
// Client connects to relay as "client" role
|
||||||
const clientWs = new WebSocket(
|
const clientWs = new WebSocket(
|
||||||
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=client`
|
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=client`
|
||||||
);
|
);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -149,7 +149,7 @@ describe("E2E Relay with E2EE", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("relay only sees opaque bytes after handshake", async () => {
|
it("relay only sees opaque bytes after handshake", async () => {
|
||||||
const sessionId = "opaque-test-" + Date.now();
|
const serverId = "opaque-test-" + Date.now();
|
||||||
|
|
||||||
// Setup keys
|
// Setup keys
|
||||||
const daemonKeyPair = await generateKeyPair();
|
const daemonKeyPair = await generateKeyPair();
|
||||||
@@ -172,10 +172,10 @@ describe("E2E Relay with E2EE", () => {
|
|||||||
|
|
||||||
// Connect both
|
// Connect both
|
||||||
const daemonWs = new WebSocket(
|
const daemonWs = new WebSocket(
|
||||||
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=server`
|
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=server`
|
||||||
);
|
);
|
||||||
const clientWs = new WebSocket(
|
const clientWs = new WebSocket(
|
||||||
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=client`
|
`ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=client`
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -212,7 +212,7 @@ describe("E2E Relay with E2EE", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("wrong key cannot decrypt", async () => {
|
it("wrong key cannot decrypt", async () => {
|
||||||
const sessionId = "wrong-key-test-" + Date.now();
|
const serverId = "wrong-key-test-" + Date.now();
|
||||||
|
|
||||||
// Setup - daemon and client with correct keys
|
// Setup - daemon and client with correct keys
|
||||||
const daemonKeyPair = await generateKeyPair();
|
const daemonKeyPair = await generateKeyPair();
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
|
|||||||
liveIt("bridges encrypted traffic end-to-end", { timeout: 45_000 }, async () => {
|
liveIt("bridges encrypted traffic end-to-end", { timeout: 45_000 }, async () => {
|
||||||
await withRetry(
|
await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
const sessionId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
const serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
const serverUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(sessionId)}&role=server`;
|
const serverUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server`;
|
||||||
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(sessionId)}&role=client`;
|
const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=client`;
|
||||||
|
|
||||||
// === Key setup ===
|
// === Key setup ===
|
||||||
const daemonKeyPair = await generateKeyPair();
|
const daemonKeyPair = await generateKeyPair();
|
||||||
|
|||||||
Reference in New Issue
Block a user