diff --git a/dapps/pos-app/.env.example b/dapps/pos-app/.env.example index 84b43916..2c0295ae 100644 --- a/dapps/pos-app/.env.example +++ b/dapps/pos-app/.env.example @@ -4,6 +4,6 @@ SENTRY_AUTH_TOKEN="" EXPO_PUBLIC_API_URL="" EXPO_PUBLIC_GATEWAY_URL="" EXPO_PUBLIC_DEFAULT_MERCHANT_ID="" -EXPO_PUBLIC_DEFAULT_MERCHANT_API_KEY="" +EXPO_PUBLIC_DEFAULT_PARTNER_API_KEY="" EXPO_PUBLIC_MERCHANT_API_URL="" EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY="" \ No newline at end of file diff --git a/dapps/pos-app/AGENTS.md b/dapps/pos-app/AGENTS.md index 8caeac76..608f0c4d 100644 --- a/dapps/pos-app/AGENTS.md +++ b/dapps/pos-app/AGENTS.md @@ -258,7 +258,7 @@ SENTRY_AUTH_TOKEN="" # Sentry authentication token EXPO_PUBLIC_API_URL="" # Payment API base URL EXPO_PUBLIC_GATEWAY_URL="" # WalletConnect gateway URL EXPO_PUBLIC_DEFAULT_MERCHANT_ID="" # Default merchant ID (optional) -EXPO_PUBLIC_DEFAULT_MERCHANT_API_KEY="" # Default merchant API key (optional) +EXPO_PUBLIC_DEFAULT_PARTNER_API_KEY="" # Default partner API key (optional) EXPO_PUBLIC_MERCHANT_API_URL="" # Merchant Portal API base URL EXPO_PUBLIC_MERCHANT_PORTAL_API_KEY="" # Merchant Portal API key (for Activity screen) ``` @@ -678,11 +678,11 @@ const { data, isLoading, error } = usePaymentStatus(paymentId, { import { secureStorage, SECURE_STORAGE_KEYS } from "@/utils/secure-storage"; // Store -await secureStorage.setItem(SECURE_STORAGE_KEYS.MERCHANT_API_KEY, apiKey); +await secureStorage.setItem(SECURE_STORAGE_KEYS.PARTNER_API_KEY, apiKey); // Retrieve const apiKey = await secureStorage.getItem( - SECURE_STORAGE_KEYS.MERCHANT_API_KEY, + SECURE_STORAGE_KEYS.PARTNER_API_KEY, ); ``` diff --git a/dapps/pos-app/__tests__/services/payment.test.ts b/dapps/pos-app/__tests__/services/payment.test.ts index 48ac2f63..a3506101 100644 --- a/dapps/pos-app/__tests__/services/payment.test.ts +++ b/dapps/pos-app/__tests__/services/payment.test.ts @@ -42,10 +42,10 @@ describe("Payment Service", () => { describe("getApiHeaders (via startPayment/getPaymentStatus)", () => { it("should throw error when merchant ID is not configured", async () => { // Set API key but not merchant ID - await SecureStore.setItemAsync("merchant_api_key", "test-api-key"); + await SecureStore.setItemAsync("partner_api_key", "test-api-key"); useSettingsStore.setState({ merchantId: null, - isMerchantApiKeySet: true, + isPartnerApiKeySet: true, }); await expect( @@ -57,10 +57,10 @@ describe("Payment Service", () => { }); it("should throw error when merchant ID is empty string", async () => { - await SecureStore.setItemAsync("merchant_api_key", "test-api-key"); + await SecureStore.setItemAsync("partner_api_key", "test-api-key"); useSettingsStore.setState({ merchantId: " ", // whitespace only - isMerchantApiKeySet: true, + isPartnerApiKeySet: true, }); await expect( @@ -74,7 +74,7 @@ describe("Payment Service", () => { it("should throw error when API key is not configured", async () => { useSettingsStore.setState({ merchantId: "merchant-123", - isMerchantApiKeySet: false, + isPartnerApiKeySet: false, }); // Don't set the API key in secure storage @@ -83,7 +83,7 @@ describe("Payment Service", () => { referenceId: "ref-123", amount: { value: "1000", unit: "cents" }, }), - ).rejects.toThrow("Merchant API key is not configured"); + ).rejects.toThrow("Partner API key is not configured"); }); it("should include correct headers when merchant is configured", async () => { diff --git a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts index a14bd798..5eb7e35e 100644 --- a/dapps/pos-app/__tests__/store/useSettingsStore.test.ts +++ b/dapps/pos-app/__tests__/store/useSettingsStore.test.ts @@ -34,9 +34,9 @@ describe("useSettingsStore", () => { expect(merchantId).toBeNull(); }); - it("should have isMerchantApiKeySet as false", () => { - const { isMerchantApiKeySet } = useSettingsStore.getState(); - expect(isMerchantApiKeySet).toBe(false); + it("should have isPartnerApiKeySet as false", () => { + const { isPartnerApiKeySet } = useSettingsStore.getState(); + expect(isPartnerApiKeySet).toBe(false); }); it("should have zero failed PIN attempts", () => { @@ -155,57 +155,57 @@ describe("useSettingsStore", () => { }); }); - describe("setMerchantApiKey / clearMerchantApiKey / getMerchantApiKey", () => { + describe("setPartnerApiKey / clearPartnerApiKey / getPartnerApiKey", () => { it("should store API key in secure storage", async () => { - const { setMerchantApiKey } = useSettingsStore.getState(); + const { setPartnerApiKey } = useSettingsStore.getState(); - await setMerchantApiKey("api-key-123"); + await setPartnerApiKey("api-key-123"); - expect(useSettingsStore.getState().isMerchantApiKeySet).toBe(true); + expect(useSettingsStore.getState().isPartnerApiKeySet).toBe(true); expect(SecureStore.setItemAsync).toHaveBeenCalledWith( - "merchant_api_key", + "partner_api_key", "api-key-123", ); }); it("should retrieve API key from secure storage", async () => { // First store the key - await useSettingsStore.getState().setMerchantApiKey("api-key-456"); + await useSettingsStore.getState().setPartnerApiKey("api-key-456"); // Then retrieve it - const apiKey = await useSettingsStore.getState().getMerchantApiKey(); + const apiKey = await useSettingsStore.getState().getPartnerApiKey(); expect(apiKey).toBe("api-key-456"); }); it("should clear API key from secure storage", async () => { // First store a key - await useSettingsStore.getState().setMerchantApiKey("api-key-789"); - expect(useSettingsStore.getState().isMerchantApiKeySet).toBe(true); + await useSettingsStore.getState().setPartnerApiKey("api-key-789"); + expect(useSettingsStore.getState().isPartnerApiKeySet).toBe(true); // Clear it - await useSettingsStore.getState().clearMerchantApiKey(); + await useSettingsStore.getState().clearPartnerApiKey(); - expect(useSettingsStore.getState().isMerchantApiKeySet).toBe(false); + expect(useSettingsStore.getState().isPartnerApiKeySet).toBe(false); expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith( - "merchant_api_key", + "partner_api_key", ); }); it("should remove API key when setting null", async () => { // First store a key - await useSettingsStore.getState().setMerchantApiKey("api-key-to-remove"); + await useSettingsStore.getState().setPartnerApiKey("api-key-to-remove"); // Set to null - await useSettingsStore.getState().setMerchantApiKey(null); + await useSettingsStore.getState().setPartnerApiKey(null); expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith( - "merchant_api_key", + "partner_api_key", ); }); it("should return null when no API key is stored", async () => { - const apiKey = await useSettingsStore.getState().getMerchantApiKey(); + const apiKey = await useSettingsStore.getState().getPartnerApiKey(); expect(apiKey).toBeNull(); }); }); diff --git a/dapps/pos-app/__tests__/utils/secure-storage.test.ts b/dapps/pos-app/__tests__/utils/secure-storage.test.ts new file mode 100644 index 00000000..84df358f --- /dev/null +++ b/dapps/pos-app/__tests__/utils/secure-storage.test.ts @@ -0,0 +1,105 @@ +/** + * Secure Storage Tests + * + * Tests for the secure storage migration function. + */ + +import { migratePartnerApiKey } from "@/utils/secure-storage"; +import { storage } from "@/utils/storage"; + +// Get the mocked secure store +const SecureStore = require("expo-secure-store"); + +describe("migratePartnerApiKey", () => { + beforeEach(() => { + // Clear secure storage mock between tests + if (SecureStore.__clearMockStorage) { + SecureStore.__clearMockStorage(); + } + // Clear the migration completed flag + storage.removeItem("migration_partner_api_key_completed"); + }); + + it("should migrate from old key to new key when old key exists", async () => { + // Set up old key + await SecureStore.setItemAsync("merchant_api_key", "test-api-key-123"); + + const migrated = await migratePartnerApiKey(); + + expect(migrated).toBe(true); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + "partner_api_key", + "test-api-key-123", + ); + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("merchant_api_key"); + }); + + it("should not overwrite existing new key", async () => { + // Set up both old and new keys + await SecureStore.setItemAsync("merchant_api_key", "old-api-key"); + await SecureStore.setItemAsync("partner_api_key", "existing-new-key"); + + // Clear mock calls from setup + jest.clearAllMocks(); + + const migrated = await migratePartnerApiKey(); + + // Should still delete old key but not set new key (existing value preserved) + expect(migrated).toBe(false); + expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith( + "partner_api_key", + expect.anything(), + ); + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("merchant_api_key"); + }); + + it("should do nothing when old key does not exist", async () => { + // No keys set up + + const migrated = await migratePartnerApiKey(); + + expect(migrated).toBe(false); + expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith( + "partner_api_key", + expect.anything(), + ); + expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled(); + }); + + it("should track migration completion and skip on subsequent calls", async () => { + // Set up old key + await SecureStore.setItemAsync("merchant_api_key", "test-api-key"); + + // First call should perform migration + const firstResult = await migratePartnerApiKey(); + expect(firstResult).toBe(true); + + // Clear mocks but keep storage state + jest.clearAllMocks(); + + // Set up old key again to simulate what would happen if migration ran again + await SecureStore.setItemAsync("merchant_api_key", "another-key"); + + // Second call should skip due to completion flag + const secondResult = await migratePartnerApiKey(); + expect(secondResult).toBe(false); + + // Should not have attempted to read/write secure storage for migration + // (only the setup call above) + expect(SecureStore.getItemAsync).not.toHaveBeenCalledWith("merchant_api_key"); + }); + + it("should properly clean up old key after migration", async () => { + await SecureStore.setItemAsync("merchant_api_key", "api-key-to-migrate"); + + await migratePartnerApiKey(); + + // Verify old key was deleted + const oldValue = await SecureStore.getItemAsync("merchant_api_key"); + expect(oldValue).toBeNull(); + + // Verify new key has the value + const newValue = await SecureStore.getItemAsync("partner_api_key"); + expect(newValue).toBe("api-key-to-migrate"); + }); +}); diff --git a/dapps/pos-app/__tests__/utils/store-helpers.ts b/dapps/pos-app/__tests__/utils/store-helpers.ts index f25586ba..bda274ae 100644 --- a/dapps/pos-app/__tests__/utils/store-helpers.ts +++ b/dapps/pos-app/__tests__/utils/store-helpers.ts @@ -16,7 +16,7 @@ export function resetSettingsStore() { variant: "default", _hasHydrated: false, merchantId: null, - isMerchantApiKeySet: false, + isPartnerApiKeySet: false, pinFailedAttempts: 0, pinLockoutUntil: null, biometricEnabled: false, @@ -51,11 +51,11 @@ export async function setupTestMerchant( ): Promise<() => Promise> { // eslint-disable-next-line @typescript-eslint/no-require-imports const SecureStore = require("expo-secure-store"); - await SecureStore.setItemAsync("merchant_api_key", apiKey); + await SecureStore.setItemAsync("partner_api_key", apiKey); useSettingsStore.setState({ merchantId, - isMerchantApiKeySet: true, + isPartnerApiKeySet: true, }); // Return cleanup function for use in afterEach or manual cleanup @@ -68,10 +68,10 @@ export async function setupTestMerchant( export async function clearTestMerchant() { // eslint-disable-next-line @typescript-eslint/no-require-imports const SecureStore = require("expo-secure-store"); - await SecureStore.deleteItemAsync("merchant_api_key"); + await SecureStore.deleteItemAsync("partner_api_key"); useSettingsStore.setState({ merchantId: null, - isMerchantApiKeySet: false, + isPartnerApiKeySet: false, }); } diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index 1404ff9d..9351af8e 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -71,6 +71,8 @@ export default Sentry.wrap(function RootLayout() { }); useEffect(() => { + // clearStaleSecureStorage handles fresh installs (clears secure storage on first launch) + // Migration is handled in useSettingsStore's onRehydrateStorage to avoid race conditions clearStaleSecureStorage(); }, []); diff --git a/dapps/pos-app/app/index.tsx b/dapps/pos-app/app/index.tsx index dcb8c164..33677607 100644 --- a/dapps/pos-app/app/index.tsx +++ b/dapps/pos-app/app/index.tsx @@ -17,10 +17,10 @@ export default function HomeScreen() { ]); const Theme = useTheme(); - const { merchantId, isMerchantApiKeySet } = useSettingsStore(); + const { merchantId, isPartnerApiKeySet } = useSettingsStore(); const handleStartPayment = () => { - if (!merchantId || !isMerchantApiKeySet) { + if (!merchantId || !isPartnerApiKeySet) { router.push("/settings"); showErrorToast("Merchant information not configured"); return; diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index fa8258b9..da8a540d 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -55,16 +55,16 @@ export default function SettingsScreen() { const { merchantIdInput, - merchantApiKeyInput, + partnerApiKeyInput, activeModal, pinError, isMerchantIdConfirmDisabled, - isMerchantApiKeyConfirmDisabled, - hasStoredMerchantApiKey, + isPartnerApiKeyConfirmDisabled, + hasStoredPartnerApiKey, handleMerchantIdInputChange, - handleMerchantApiKeyInputChange, + handlePartnerApiKeyInputChange, handleMerchantIdConfirm, - handleMerchantApiKeyConfirm, + handlePartnerApiKeyConfirm, handlePinVerifyComplete, handleBiometricAuthSuccess, handleBiometricAuthFailure, @@ -260,12 +260,12 @@ export default function SettingsScreen() {