| 1 | import { |
| 2 | BearerConnectionCredential, |
| 3 | BearerConnectionProfile, |
| 4 | BearerConnectionRegistration, |
| 5 | BearerConnectionTarget, |
| 6 | CredentialStore, |
| 7 | HubConnectionCredential, |
| 8 | } from "@t3tools/client-runtime/connection"; |
| 9 | import { EnvironmentId } from "@t3tools/contracts"; |
| 10 | import { Persistence } from "@t3tools/client-runtime/platform"; |
| 11 | import * as Option from "effect/Option"; |
| 12 | import { describe, expect, it } from "@effect/vitest"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import { vi } from "vite-plus/test"; |
| 15 | |
| 16 | vi.mock("react-native", () => ({ |
| 17 | Platform: { OS: "ios" }, |
| 18 | })); |
| 19 | |
| 20 | vi.mock("expo-secure-store", () => ({ |
| 21 | deleteItemAsync: vi.fn(), |
| 22 | getItemAsync: vi.fn(), |
| 23 | setItemAsync: vi.fn(), |
| 24 | })); |
| 25 | |
| 26 | import * as ConnectionStorage from "./storage"; |
| 27 | import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store"; |
| 28 | import * as MobileSecureStorage from "../persistence/mobile-secure-storage"; |
| 29 | |
| 30 | function makeStorage(initial: Readonly<Record<string, string>>) { |
| 31 | const values = new Map(Object.entries(initial)); |
| 32 | const deleted: Array<string> = []; |
| 33 | const storage = MobileSecureStorage.MobileSecureStorage.of({ |
| 34 | getItem: (key) => Effect.sync(() => values.get(key) ?? null), |
| 35 | setItem: (key, value) => |
| 36 | Effect.sync(() => { |
| 37 | values.set(key, value); |
| 38 | }), |
| 39 | removeItem: (key) => |
| 40 | Effect.sync(() => { |
| 41 | deleted.push(key); |
| 42 | values.delete(key); |
| 43 | }), |
| 44 | }); |
| 45 | return { deleted, storage, values }; |
| 46 | } |
| 47 | |
| 48 | describe("mobile connection catalog storage", () => { |
| 49 | it.effect("restores hub route pointers and the single bearer after reopening storage", () => |
| 50 | Effect.gen(function* () { |
| 51 | const memory = makeStorage({}); |
| 52 | const connectionId = "hub:hub:source"; |
| 53 | const hubCredentialId = "hub-session:hub"; |
| 54 | const credential = new HubConnectionCredential({ connectionId: hubCredentialId }); |
| 55 | const bearer = new BearerConnectionCredential({ token: "single-hub-secret" }); |
| 56 | const registration = new BearerConnectionRegistration({ |
| 57 | target: new BearerConnectionTarget({ |
| 58 | connectionId, |
| 59 | environmentId: EnvironmentId.make("source"), |
| 60 | label: "Source", |
| 61 | }), |
| 62 | profile: new BearerConnectionProfile({ |
| 63 | connectionId, |
| 64 | environmentId: EnvironmentId.make("source"), |
| 65 | label: "Source", |
| 66 | httpBaseUrl: "https://hub.example/environments/source/", |
| 67 | wsBaseUrl: "wss://hub.example/environments/source/", |
| 68 | }), |
| 69 | credential, |
| 70 | }); |
| 71 | yield* Effect.gen(function* () { |
| 72 | const credentials = yield* CredentialStore.ConnectionCredentialStore; |
| 73 | const registrations = yield* Persistence.ConnectionRegistrationStore; |
| 74 | yield* credentials.put(hubCredentialId, bearer); |
| 75 | yield* registrations.register(registration, [registration.target]); |
| 76 | expect(yield* credentials.get(connectionId)).toEqual(Option.some(credential)); |
| 77 | }).pipe( |
| 78 | Effect.provide(ConnectionStorage.layer), |
| 79 | Effect.provideService(MobileSecureStorage.MobileSecureStorage, memory.storage), |
| 80 | Effect.scoped, |
| 81 | ); |
| 82 | |
| 83 | yield* Effect.gen(function* () { |
| 84 | const credentials = yield* CredentialStore.ConnectionCredentialStore; |
| 85 | expect(yield* credentials.get(connectionId)).toEqual(Option.some(credential)); |
| 86 | expect(yield* credentials.get(hubCredentialId)).toEqual(Option.some(bearer)); |
| 87 | }).pipe( |
| 88 | Effect.provide(ConnectionStorage.layer), |
| 89 | Effect.provideService(MobileSecureStorage.MobileSecureStorage, memory.storage), |
| 90 | Effect.scoped, |
| 91 | ); |
| 92 | expect(JSON.parse(memory.values.get(CONNECTION_CATALOG_KEY)!).credentials).toEqual([ |
| 93 | { |
| 94 | connectionId: hubCredentialId, |
| 95 | credential: { _tag: "BearerConnectionCredential", token: bearer.token }, |
| 96 | }, |
| 97 | { |
| 98 | connectionId, |
| 99 | credential: { _tag: "HubConnectionCredential", connectionId: hubCredentialId }, |
| 100 | }, |
| 101 | ]); |
| 102 | }), |
| 103 | ); |
| 104 | |
| 105 | it.effect("recovers from a corrupt current catalog", () => |
| 106 | Effect.gen(function* () { |
| 107 | const memory = makeStorage({ |
| 108 | [CONNECTION_CATALOG_KEY]: "{not-json", |
| 109 | }); |
| 110 | const catalog = yield* make().pipe( |
| 111 | Effect.provideService(MobileSecureStorage.MobileSecureStorage, memory.storage), |
| 112 | ); |
| 113 | |
| 114 | expect((yield* catalog.read).targets).toEqual([]); |
| 115 | expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); |
| 116 | }), |
| 117 | ); |
| 118 | |
| 119 | it.effect("replaces and removes a corrupt legacy catalog", () => |
| 120 | Effect.gen(function* () { |
| 121 | const memory = makeStorage({ |
| 122 | [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }), |
| 123 | }); |
| 124 | const catalog = yield* make().pipe( |
| 125 | Effect.provideService(MobileSecureStorage.MobileSecureStorage, memory.storage), |
| 126 | ); |
| 127 | |
| 128 | expect((yield* catalog.read).targets).toEqual([]); |
| 129 | expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); |
| 130 | expect(memory.values.has(CONNECTION_CATALOG_KEY)).toBe(true); |
| 131 | }), |
| 132 | ); |
| 133 | |
| 134 | it.effect("falls back to valid legacy data when the current catalog is corrupt", () => |
| 135 | Effect.gen(function* () { |
| 136 | const memory = makeStorage({ |
| 137 | [CONNECTION_CATALOG_KEY]: "{not-json", |
| 138 | [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ |
| 139 | connections: [ |
| 140 | { |
| 141 | environmentId: "legacy-environment", |
| 142 | environmentLabel: "Legacy", |
| 143 | pairingUrl: "https://legacy.example.test/pair", |
| 144 | displayUrl: "https://legacy.example.test", |
| 145 | httpBaseUrl: "https://legacy.example.test", |
| 146 | wsBaseUrl: "wss://legacy.example.test", |
| 147 | bearerToken: "legacy-token", |
| 148 | authenticationMethod: "bearer", |
| 149 | }, |
| 150 | ], |
| 151 | }), |
| 152 | }); |
| 153 | const catalog = yield* make().pipe( |
| 154 | Effect.provideService(MobileSecureStorage.MobileSecureStorage, memory.storage), |
| 155 | ); |
| 156 | |
| 157 | expect((yield* catalog.read).targets).toHaveLength(1); |
| 158 | expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]); |
| 159 | |
| 160 | yield* catalog.update((document) => document); |
| 161 | expect(memory.values.has(CONNECTION_CATALOG_KEY)).toBe(true); |
| 162 | expect(memory.values.has(LEGACY_CONNECTIONS_KEY)).toBe(false); |
| 163 | }), |
| 164 | ); |
| 165 | }); |