1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2import { computeVaultId } from "../shared/sync-identity";3import { routeSourcePostSASFrame, runChirpAdoptionReceiver } from "./chirp-pair";4import {5 memoryTransportPair,6 runPairingResponderOpenFrame,7 type LocalIdentity,8 type PairingTransport,9} from "../shared/chirp-handshake";10import { createPairingAttempt } from "../shared/chirp-pairing";11import { __setGgwaveModuleForTest } from "../shared/ggwave";12import { encodeChirpPayload } from "../shared/chirp-payload";13import { CHIRP_PAYLOAD_LENGTH, DEVICE_TYPE_EXTENSION, type ChirpPayload } from "../shared/sync-types";1415const localIdentity: LocalIdentity = {16 deviceId: "source-device",17 deviceName: "Source",18 deviceType: "extension",19 longTermPublicKeyHex: "ab".repeat(32),20 signingPublicKeyHex: "cd".repeat(32),21};2223function channel(): PairingTransport & { sent: Uint8Array[]; close: ReturnType<typeof vi.fn> } {24 const sent: Uint8Array[] = [];25 return {26 sent,27 close: vi.fn(async () => {}),28 async send(msg) { sent.push(msg); },29 async receive() {30 return new TextEncoder().encode(JSON.stringify({31 type: "ADOPT_IDENTITY",32 device_id: "receiver-device",33 device_name: "Receiver",34 device_type: "extension",35 long_term_public_key_hex: "ef".repeat(32),36 }));37 },38 };39}4041function fakeGgwave() {42 return {43 ProtocolId: { GGWAVE_PROTOCOL_ULTRASOUND_FASTEST: 5 },44 GGWAVE_OPERATING_MODE_RX: 1,45 GGWAVE_OPERATING_MODE_TX: 2,46 GGWAVE_OPERATING_MODE_RX_AND_TX: 3,47 getDefaultParameters() {48 return {49 sampleRateInp: 48000,50 sampleRateOut: 48000,51 samplesPerFrame: 1024,52 soundMarkerThreshold: 3,53 payloadLength: -1,54 operatingMode: 0,55 };56 },57 init() { return 1; },58 free() {},59 encode(_inst: number, data: Uint8Array | string) {60 const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;61 const out = new Float32Array(Math.max(bytes.length, 128));62 for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] / 255;63 for (let i = bytes.length; i < out.length; i++) out[i] = 99;64 return out;65 },66 decode(_inst: number, samples: Float32Array | Int16Array) {67 if (samples.length < CHIRP_PAYLOAD_LENGTH) return null;68 const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH);69 for (let i = 0; i < CHIRP_PAYLOAD_LENGTH; i++) {70 const v = (samples as Float32Array)[i];71 if (v === 99) return null;72 buf[i] = Math.round(v * 255);73 }74 return buf;75 },76 };77}7879function fakeAudio(injectedChirp: ChirpPayload) {80 const bytes = encodeChirpPayload(injectedChirp);81 let pending = new Float32Array(128);82 for (let i = 0; i < bytes.length; i++) pending[i] = bytes[i] / 255;83 for (let i = bytes.length; i < pending.length; i++) pending[i] = 99;84 return async () => ({85 sampleRate: 48000,86 async play() { },87 async pullSamples() {88 const out = pending;89 pending = new Float32Array(0);90 return out;91 },92 async close() { },93 });94}9596describe("source-side ADOPT_REQUEST routing", () => {97 beforeEach(() => __setGgwaveModuleForTest(fakeGgwave()));98 afterEach(() => __setGgwaveModuleForTest(null));99100 it("T-A26: routes ADOPT_REQUEST to grantAdoption after SAS confirmation and consent", async () => {101 const rootKeyHex = "77".repeat(32);102 const ch = channel();103 const completePairing = vi.fn();104 const result = await routeSourcePostSASFrame(105 {106 sas: "895245",107 frame: {108 type: "ADOPT_REQUEST",109 device_id: "receiver-ephemeral",110 device_name: "Receiver",111 device_type: "extension",112 },113 channel: ch,114 },115 localIdentity,116 {117 confirmSAS: vi.fn(async () => true),118 completePairing,119 grantAdoptionConsent: vi.fn(async () => true),120 fetchAdoptionMaterial: vi.fn(async () => ({121 rootKeyHex,122 vaultId: await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))),123 license: "PPK-test",124 })),125 onProgress: vi.fn(),126 },127 );128129 const grant = JSON.parse(new TextDecoder().decode(ch.sent[0]));130 expect(result).toEqual(expect.objectContaining({ deviceId: "receiver-device" }));131 expect(grant).toMatchObject({132 type: "ADOPT_GRANT",133 root_key_hex: rootKeyHex,134 vault_id: await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))),135 license: "PPK-test",136 });137 expect(completePairing).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-device" }));138 });139140 it("T-A27: routes PAIR_IDENTITY to the existing pairing path", async () => {141 const ch = channel();142 const completePairing = vi.fn();143 const result = await routeSourcePostSASFrame(144 {145 sas: "895245",146 frame: {147 type: "PAIR_IDENTITY",148 device_id: "phone",149 device_name: "Phone",150 device_type: "android",151 long_term_public_key_hex: "aa".repeat(32),152 },153 channel: ch,154 },155 localIdentity,156 {157 confirmSAS: vi.fn(async () => true),158 completePairing,159 onProgress: vi.fn(),160 },161 );162163 const response = JSON.parse(new TextDecoder().decode(ch.sent[0]));164 expect(response.type).toBe("PAIR_IDENTITY");165 expect(result).toEqual(expect.objectContaining({ deviceId: "phone" }));166 expect(completePairing).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "phone" }));167 });168169 it("T-A28: unknown frame closes the channel gracefully", async () => {170 const ch = channel();171 await expect(routeSourcePostSASFrame(172 { sas: "895245", frame: { type: "WHAT_IS_THIS" }, channel: ch },173 localIdentity,174 {175 confirmSAS: vi.fn(async () => true),176 completePairing: vi.fn(),177 onProgress: vi.fn(),178 },179 )).rejects.toThrow("unexpected_pairing_frame");180181 expect(ch.close).toHaveBeenCalledTimes(1);182 });183184 it("T-A29: source root key bytes are zeroed after grantAdoption regardless of outcome", async () => {185 const fillSpy = vi.spyOn(Uint8Array.prototype, "fill");186 await routeSourcePostSASFrame(187 {188 sas: "895245",189 frame: {190 type: "ADOPT_REQUEST",191 device_id: "receiver-ephemeral",192 device_name: "Receiver",193 device_type: "extension",194 },195 channel: channel(),196 },197 localIdentity,198 {199 confirmSAS: vi.fn(async () => true),200 completePairing: vi.fn(),201 grantAdoptionConsent: vi.fn(async () => true),202 fetchAdoptionMaterial: vi.fn(async () => ({203 rootKeyHex: "88".repeat(32),204 vaultId: await computeVaultId(Uint8Array.from(Buffer.from("88".repeat(32), "hex"))),205 })),206 onProgress: vi.fn(),207 },208 );209210 expect(fillSpy).toHaveBeenCalledWith(0);211 });212213 it("T-A33: receiver onboarding orchestrator sends ADOPT_REQUEST and persists the granted source", async () => {214 const sourceAttempt = createPairingAttempt(DEVICE_TYPE_EXTENSION);215 const [receiverTransport, sourceTransport] = memoryTransportPair();216 const rootKeyHex = "99".repeat(32);217 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));218 const receiverUpsert = vi.fn();219 const sourceUpsert = vi.fn();220221 const source = (async () => {222 const postNoise = await runPairingResponderOpenFrame(223 sourceAttempt.ephemeralPrivateKey,224 sourceAttempt.ephemeralPublicKey,225 sourceTransport,226 );227 return routeSourcePostSASFrame(postNoise, localIdentity, {228 confirmSAS: vi.fn(async () => true),229 completePairing: sourceUpsert,230 grantAdoptionConsent: vi.fn(async () => true),231 fetchAdoptionMaterial: vi.fn(async () => ({ rootKeyHex, vaultId })),232 onProgress: vi.fn(),233 });234 })();235236 const receiver = await runChirpAdoptionReceiver({237 deviceName: "Receiver",238 deviceType: "extension",239 openAudio: fakeAudio(sourceAttempt.outgoingPayload),240 openPairingServer: vi.fn(async () => ({241 transport: { ...receiverTransport, close: vi.fn(async () => {}) },242 peerIp: "127.0.0.1",243 })),244 confirmSAS: vi.fn(async () => true),245 createVaultFromRootKey: vi.fn(async (adoptedRootKeyHex) => {246 expect(adoptedRootKeyHex).toBe(rootKeyHex);247 return { vaultId };248 }),249 deriveLocalIdentity: vi.fn(async () => ({250 deviceId: "receiver-device",251 deviceName: "Receiver",252 deviceType: "extension" as const,253 longTermPublicKeyHex: "ef".repeat(32),254 signingPublicKeyHex: "12".repeat(32),255 })),256 completePairing: receiverUpsert,257 onProgress: vi.fn(),258 });259 await source;260261 expect(receiver).toEqual(expect.objectContaining({ deviceId: "source-device" }));262 expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "source-device" }));263 expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-device" }));264 });265});