1import {
2 BearerConnectionCredential,
3 BearerConnectionProfile,
4 BearerConnectionRegistration,
5 BearerConnectionTarget,
6 CredentialStore,
7 HubConnectionCredential,
8} from "@t3tools/client-runtime/connection";
9import { EnvironmentId } from "@t3tools/contracts";
10import { Persistence } from "@t3tools/client-runtime/platform";
11import * as Option from "effect/Option";
12import { describe, expect, it } from "@effect/vitest";
13import * as Effect from "effect/Effect";
14import { vi } from "vite-plus/test";
15
16vi.mock("react-native", () => ({
17 Platform: { OS: "ios" },
18}));
19
20vi.mock("expo-secure-store", () => ({
21 deleteItemAsync: vi.fn(),
22 getItemAsync: vi.fn(),
23 setItemAsync: vi.fn(),
24}));
25
26import * as ConnectionStorage from "./storage";
27import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store";
28import * as MobileSecureStorage from "../persistence/mobile-secure-storage";
29
30function 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
48describe("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});