Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dapps/pos-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
6 changes: 3 additions & 3 deletions dapps/pos-app/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down Expand Up @@ -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,
);
```

Expand Down
12 changes: 6 additions & 6 deletions dapps/pos-app/__tests__/services/payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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

Expand All @@ -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 () => {
Expand Down
38 changes: 19 additions & 19 deletions dapps/pos-app/__tests__/store/useSettingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
Expand Down
105 changes: 105 additions & 0 deletions dapps/pos-app/__tests__/utils/secure-storage.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
10 changes: 5 additions & 5 deletions dapps/pos-app/__tests__/utils/store-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function resetSettingsStore() {
variant: "default",
_hasHydrated: false,
merchantId: null,
isMerchantApiKeySet: false,
isPartnerApiKeySet: false,
pinFailedAttempts: 0,
pinLockoutUntil: null,
biometricEnabled: false,
Expand Down Expand Up @@ -51,11 +51,11 @@ export async function setupTestMerchant(
): Promise<() => Promise<void>> {
// 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
Expand All @@ -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,
});
}
2 changes: 2 additions & 0 deletions dapps/pos-app/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}, []);

Expand Down
4 changes: 2 additions & 2 deletions dapps/pos-app/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 12 additions & 12 deletions dapps/pos-app/app/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -260,12 +260,12 @@ export default function SettingsScreen() {
</ThemedText>
<View style={styles.merchantInputRow}>
<TextInput
value={merchantApiKeyInput}
onChangeText={handleMerchantApiKeyInputChange}
value={partnerApiKeyInput}
onChangeText={handlePartnerApiKeyInputChange}
placeholder={
hasStoredMerchantApiKey
hasStoredPartnerApiKey
? "****************"
: "Enter merchant API key"
: "Enter partner API key"
}
placeholderTextColor={theme["text-tertiary"]}
autoCapitalize="none"
Expand All @@ -281,12 +281,12 @@ export default function SettingsScreen() {
]}
/>
<Button
onPress={handleMerchantApiKeyConfirm}
disabled={isMerchantApiKeyConfirmDisabled}
onPress={handlePartnerApiKeyConfirm}
disabled={isPartnerApiKeyConfirmDisabled}
style={[
styles.confirmButton,
{
backgroundColor: isMerchantApiKeyConfirmDisabled
backgroundColor: isPartnerApiKeyConfirmDisabled
? theme["foreground-tertiary"]
: theme["bg-accent-primary"],
},
Expand Down
Loading