diff --git a/__tests__/services/analytics/user.test.ts b/__tests__/services/analytics/user.test.ts new file mode 100644 index 000000000..fb85fce40 --- /dev/null +++ b/__tests__/services/analytics/user.test.ts @@ -0,0 +1,90 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { STORAGE_KEYS } from "services/analytics/constants"; +import { getUserId } from "services/analytics/user"; +import { getAuthUserId } from "services/auth/getAuthUserId"; + +// jest.setup.js globally stubs services/analytics/user (getUserId always +// resolves "test-user-id") for the rest of the suite. Unmock it here so this +// file exercises the real implementation. +jest.unmock("services/analytics/user"); + +// Mock AsyncStorage directly (same pattern used across the mobile test suite). +jest.mock("@react-native-async-storage/async-storage", () => ({ + getItem: jest.fn(), + setItem: jest.fn(), +})); + +// user.ts imports the real @amplitude/analytics-react-native package (jest.setup.js's +// blanket mock of services/analytics/user is unmocked above), which otherwise +// pulls in its own native AsyncStorage requirement at import time. +jest.mock("@amplitude/analytics-react-native", () => ({ + setUserId: jest.fn(), +})); + +// Mock only the immediate dependency: getAuthUserId does its own thorough +// keypair-resolution testing in getAuthUserId.test.ts. +jest.mock("services/auth/getAuthUserId", () => ({ + getAuthUserId: jest.fn(), +})); + +const mockGetItem = AsyncStorage.getItem as jest.Mock; +const mockSetItem = AsyncStorage.setItem as jest.Mock; +const mockGetAuthUserId = getAuthUserId as jest.Mock; + +describe("getUserId", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns the auth-derived id and overwrites a previously stored random id", async () => { + mockGetAuthUserId.mockResolvedValue("auth-derived-hex-id"); + mockGetItem.mockResolvedValue("old-random-id"); + + const userId = await getUserId(); + + expect(userId).toBe("auth-derived-hex-id"); + expect(mockSetItem).toHaveBeenCalledWith( + STORAGE_KEYS.METRICS_USER_ID, + "auth-derived-hex-id", + ); + }); + + it("returns the auth-derived id and persists it when nothing was stored yet", async () => { + mockGetAuthUserId.mockResolvedValue("auth-derived-hex-id"); + mockGetItem.mockResolvedValue(null); + + const userId = await getUserId(); + + expect(userId).toBe("auth-derived-hex-id"); + expect(mockSetItem).toHaveBeenCalledWith( + STORAGE_KEYS.METRICS_USER_ID, + "auth-derived-hex-id", + ); + }); + + it("falls back to the existing stored id when getAuthUserId resolves null (locked session)", async () => { + mockGetAuthUserId.mockResolvedValue(null); + mockGetItem.mockResolvedValue("existing-random-id"); + + const userId = await getUserId(); + + expect(userId).toBe("existing-random-id"); + // The pre-existing random path is untouched: no re-persist of an + // already-stored id. + expect(mockSetItem).not.toHaveBeenCalled(); + }); + + it("generates and persists a new random id when getAuthUserId resolves null and nothing is stored", async () => { + mockGetAuthUserId.mockResolvedValue(null); + mockGetItem.mockResolvedValue(null); + + const userId = await getUserId(); + + expect(userId).toEqual(expect.any(String)); + expect(userId.length).toBeGreaterThan(0); + expect(mockSetItem).toHaveBeenCalledWith( + STORAGE_KEYS.METRICS_USER_ID, + userId, + ); + }); +}); diff --git a/__tests__/services/auth/getAuthUserId.test.ts b/__tests__/services/auth/getAuthUserId.test.ts new file mode 100644 index 000000000..ef8540237 --- /dev/null +++ b/__tests__/services/auth/getAuthUserId.test.ts @@ -0,0 +1,28 @@ +import { getAuthKeypair } from "services/auth/getAuthKeypair"; +import { getAuthUserId } from "services/auth/getAuthUserId"; + +// Mock only the immediate dependency: getAuthKeypair does its own thorough +// derivation/caching/session-gate testing in getAuthKeypair.test.ts. +jest.mock("services/auth/getAuthKeypair", () => ({ + getAuthKeypair: jest.fn(), +})); + +const mockGetAuthKeypair = getAuthKeypair as jest.Mock; + +describe("getAuthUserId", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns the public hex when a keypair is available", async () => { + mockGetAuthKeypair.mockResolvedValue({ + rawPublicKey: () => Buffer.from("ff", "hex"), + }); + expect(await getAuthUserId()).toBe("ff"); + }); + + it("returns null when the session has no keypair", async () => { + mockGetAuthKeypair.mockResolvedValue(null); + expect(await getAuthUserId()).toBeNull(); + }); +}); diff --git a/src/services/analytics/user.ts b/src/services/analytics/user.ts index bc352a233..dddc50f2b 100644 --- a/src/services/analytics/user.ts +++ b/src/services/analytics/user.ts @@ -4,6 +4,7 @@ import { logger } from "config/logger"; import { useAnalyticsStore } from "ducks/analytics"; import { STORAGE_KEYS, DEBUG_CONFIG } from "services/analytics/constants"; import { isInitialized } from "services/analytics/core"; +import { getAuthUserId } from "services/auth/getAuthUserId"; // ----------------------------------------------------------------------------- // USER ID MANAGEMENT @@ -19,12 +20,35 @@ const generateRandomUserId = (): string => Math.random().toString().split(".")[1]; /** - * Gets user ID with fallback strategy: - * 1. Try to get from AsyncStorage + * Gets user ID, preferring the seed-derived auth id, with fallback strategy: + * 0. Prefer the auth id (services/auth/getAuthUserId) when the session is + * unlocked, persisting/overwriting the stored id so migrated users keep a + * stable identity going forward. + * 1. Otherwise (locked / no session) try to get from AsyncStorage * 2. Generate new one and store it * 3. Use session-only ID if storage fails */ export const getUserId = async (): Promise => { + const authId = await getAuthUserId(); + + if (authId) { + try { + await AsyncStorage.setItem(STORAGE_KEYS.METRICS_USER_ID, authId); + } catch (setError) { + // Same rationale as the random-id session-only fallback below: don't + // let a persistence failure discard an id we already have. + logger.warn( + DEBUG_CONFIG.LOG_PREFIX, + "Failed to persist auth-derived user ID, using it for this session only", + setError, + ); + } + + sessionUserId = authId; + + return authId; + } + try { const storedId = await AsyncStorage.getItem(STORAGE_KEYS.METRICS_USER_ID); diff --git a/src/services/auth/getAuthUserId.ts b/src/services/auth/getAuthUserId.ts new file mode 100644 index 000000000..4a5d45416 --- /dev/null +++ b/src/services/auth/getAuthUserId.ts @@ -0,0 +1,11 @@ +import { getAuthKeypair } from "services/auth/getAuthKeypair"; + +/** + * Public auth user id (hex) for analytics; null when the session is locked. + * Only the public key hex is ever returned — the underlying keypair (and any + * private key material) never leaves `getAuthKeypair`. + */ +export const getAuthUserId = async (): Promise => { + const keypair = await getAuthKeypair(); + return keypair ? keypair.rawPublicKey().toString("hex") : null; +};