import { describe, expect, it } from "vitest"; import { exportGoogleMigration, exportCsv, importCsv, importGoogleMigrationBatch, importOtpAuthList, importPlainPskc, } from "../../src/otp/migration"; import { bytesToBase64Url, utf8ToBytes } from "../../src/crypto/encoding"; import type { OtpProfile } from "../../src/otp/profile"; const profile: OtpProfile = { kind: "totp", account: 'alice,"admin"', issuer: "Example", secret: utf8ToBytes("12345678901234567890"), algorithm: "SHA-256", digits: 8, period: 45, counter: 0n, extensions: new Map(), }; function varint(value: number): number[] { const output: number[] = []; let remaining = value; do { let byte = remaining & 0x7f; remaining >>>= 7; if (remaining) byte |= 0x80; output.push(byte); } while (remaining); return output; } function field(number: number, value: number | Uint8Array): number[] { return typeof value === "number" ? [...varint(number << 3), ...varint(value)] : [...varint((number << 3) | 2), ...varint(value.length), ...value]; } function googlePart(index: number, size = 2, id = 73): string { const credential = Uint8Array.from([ ...field(1, utf8ToBytes("12345678901234567890")), ...field(2, utf8ToBytes(`user-${index}`)), ...field(3, utf8ToBytes("Example")), ...field(4, 1), ...field(5, 1), ...field(6, 2), ]); const payload = Uint8Array.from([ ...field(1, credential), ...field(2, 1), ...field(3, size), ...field(4, index), ...field(5, id), ]); return `otpauth-migration://offline?data=${bytesToBase64Url(payload)}`; } describe("OTP migrations", () => { it("round-trips the documented CSV including quoting", () => { const result = importCsv(exportCsv([profile])); expect(result.profiles[0]).toMatchObject({ account: profile.account, issuer: "Example", algorithm: "SHA-256", digits: 8, period: 45, }); expect([...result.profiles[0]!.secret]).toEqual([...profile.secret]); }); it("neutralizes spreadsheet formulas without changing a round trip", () => { const dangerous = { ...profile, account: '=HYPERLINK("https://bad")' }; const csv = exportCsv([dangerous]); expect(csv).toContain("'=HYPERLINK"); expect(importCsv(csv).profiles[0]!.account).toBe(dangerous.account); }); it("reports the failing line in URI lists", () => { expect(() => importOtpAuthList("otpauth://totp/Good?secret=JBSWY3DPEHPK3PXP\nnope"), ).toThrow(/Line 2/iu); }); it("imports plain-secret PSKC and refuses encrypted keys", () => { const xml = `ExampleMTIzNDU2Nzg5MDEyMzQ1Njc4OTA=30OTP`; expect(importPlainPskc(xml).profiles[0]).toMatchObject({ kind: "totp", account: "alice", issuer: "Example", digits: 6, }); expect(() => importPlainPskc( xml.replace( "MTIzNDU2Nzg5MDEyMzQ1Njc4OTA=", "", ), ), ).toThrow(/encrypted/iu); }); it("assembles Google multi-QR batches in index order", () => { const result = importGoogleMigrationBatch([googlePart(1), googlePart(0)]); expect(result.profiles.map((item) => item.account)).toEqual([ "user-0", "user-1", ]); expect(() => importGoogleMigrationBatch([googlePart(0)])).toThrow( /missing part 2/iu, ); expect(() => importGoogleMigrationBatch([googlePart(0), googlePart(1, 2, 99)]), ).toThrow(/different batches/iu); }); it("exports and reassembles Google migration batches", () => { const portable = { ...profile, period: 30 }; const exported = exportGoogleMigration( [portable, { ...portable, account: "bob" }], 1, ); expect(exported.uris).toHaveLength(2); const imported = importGoogleMigrationBatch(exported.uris); expect(imported.profiles.map(({ account }) => account)).toEqual([ profile.account, "bob", ]); expect(imported.profiles[0]).toMatchObject({ algorithm: "SHA-256", digits: 8, period: 30, }); }); it("refuses lossy Google exports", () => { expect(() => exportGoogleMigration([profile])).toThrow(/period or epoch/u); }); });