Skip to content
Draft
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
16 changes: 12 additions & 4 deletions .github/scripts/android-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@ set -euo pipefail
trap 'adb logcat -d > "$GITHUB_WORKSPACE/android-logcat.txt" || true' EXIT

cd "$GITHUB_WORKSPACE/android"
./gradlew :app:assembleRelease -PreactNativeArchitectures=x86_64 --no-daemon
APK_PATH="$(find app/build/outputs/apk/release -maxdepth 1 -name '*.apk' -print -quit)"
./gradlew \
:app:assembleDebug \
-PRING_BUNDLE_DEBUG_JS=true \
-PreactNativeArchitectures=x86_64 \
--no-daemon
APK_PATH="$(find app/build/outputs/apk/debug -maxdepth 1 -name '*.apk' -print -quit)"
if [[ -z "$APK_PATH" ]]; then
echo "No release APK found in app/build/outputs/apk/release" >&2
echo "No debug APK found in app/build/outputs/apk/debug" >&2
exit 1
fi
if ! unzip -tqq "$APK_PATH" assets/index.android.bundle >/dev/null; then
echo "Debug E2E APK is missing its packaged JavaScript bundle" >&2
exit 1
fi
adb install -r "$APK_PATH"
Expand All @@ -25,4 +33,4 @@ INVITE_CODE_COMPACT="${INVITE_CODE//-/}"
echo "::add-mask::$INVITE_CODE"
echo "::add-mask::$INVITE_CODE_COMPACT"

maestro --platform=android test -e APP_ID=to.pubky.ring -e INVITE_CODE="$INVITE_CODE" .maestro
maestro --platform=android test -e APP_ID=app.pubkyring -e INVITE_CODE="$INVITE_CODE" .maestro
4 changes: 2 additions & 2 deletions .github/workflows/android-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ jobs:
uses: actions/upload-artifact@v7
if: always()
with:
name: android-release-apk
path: android/app/build/outputs/apk/release/*.apk
name: android-debug-apk
path: android/app/build/outputs/apk/debug/*.apk
if-no-files-found: ignore
retention-days: 7

Expand Down
2 changes: 1 addition & 1 deletion .maestro/scripts/run-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ flow="${2:-.maestro}"

case "$platform" in
android)
app_id="to.pubky.ring"
app_id="app.pubkyring"
;;
ios)
app_id="app.pubkyring"
Expand Down
6 changes: 5 additions & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { updateIsOnline } from './src/store/slices/settingsSlice.ts';
import { checkNetworkConnection } from './src/utils/helpers.ts';
import { setDeepLink } from './src/store/slices/pubkysSlice.ts';
import { parseInput } from './src/utils/inputParser.ts';
import { SharedPubkyDiscoveryContext, useSharedPubkyDiscovery } from './src/hooks/useSharedPubkyDiscovery.ts';
import './src/theme/toast';

function App(): React.JSX.Element {
Expand All @@ -25,6 +26,7 @@ function App(): React.JSX.Element {
isOnlineRef.current = isOnline;
const dispatch = useDispatch();
const { t } = useTranslation();
const sharedPubkyDiscovery = useSharedPubkyDiscovery();

// Handle deep linking
useEffect(() => {
Expand Down Expand Up @@ -116,7 +118,9 @@ function App(): React.JSX.Element {
<ThemeProvider theme={theme}>
<SafeAreaProvider>
<SafeAreaView edges={['top', 'left', 'right']}>
<RootNavigator />
<SharedPubkyDiscoveryContext.Provider value={sharedPubkyDiscovery}>
<RootNavigator />
</SharedPubkyDiscoveryContext.Provider>
</SafeAreaView>
</SafeAreaProvider>
</ThemeProvider>
Expand Down
14 changes: 12 additions & 2 deletions __tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@ jest.mock('react-native-safe-area-context', () => {
__esModule: true,
SafeAreaProvider: ({ children }: { children?: ReactNode }) =>
ReactMock.createElement(View, null, children),
SafeAreaView: ({ children }: { children?: ReactNode }) =>
ReactMock.createElement(View, null, children),
SafeAreaView: ({ children }: { children?: ReactNode }) => ReactMock.createElement(View, null, children),
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
useSafeAreaFrame: () => ({ x: 0, y: 0, width: 320, height: 640 }),
};
Expand All @@ -80,6 +79,17 @@ jest.mock('@synonymdev/react-native-toast', () => ({
showToast: jest.fn(),
}));

jest.mock('../src/hooks/useSharedPubkyDiscovery.ts', () => {
const ReactMock = require('react');
const value = { available: false, identities: [], refresh: jest.fn() };

return {
__esModule: true,
SharedPubkyDiscoveryContext: ReactMock.createContext(value),
useSharedPubkyDiscovery: () => value,
};
});

jest.mock('react-i18next', () => ({
__esModule: true,
useTranslation: () => ({
Expand Down
197 changes: 197 additions & 0 deletions __tests__/pubkyIdentityLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { err, ok } from '@synonymdev/result';
import { EBackupPreference, Pubky } from '../src/types/pubky';
import { deletePubky, reconcileOwnedSharedPubkys, savePubky } from '../src/utils/pubky';

const OWNED = 'ufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy';
const SECRET = '0123456789abcdef'.repeat(4);

const mockGetPublicKeyFromSecretKey = jest.fn();
const mockGetKeychainValue = jest.fn();
const mockSetKeychainValue = jest.fn();
const mockResetKeychainValue = jest.fn();
const mockGetAllKeychainKeys = jest.fn();
const mockGetPubkyDataFromStore = jest.fn();
const mockMirrorSharedPubky = jest.fn();
const mockRemoveSharedPubky = jest.fn();
const mockReconcileSharedPubkys = jest.fn();

jest.mock('@synonymdev/react-native-pubky', () => ({
auth: jest.fn(),
generateMnemonicPhraseAndKeypair: jest.fn(),
get: jest.fn(),
getHomeserver: jest.fn(),
getPublicKeyFromSecretKey: (...args: unknown[]) => mockGetPublicKeyFromSecretKey(...args),
getSignupToken: jest.fn(),
mnemonicPhraseToKeypair: jest.fn(),
republishHomeserver: jest.fn(),
signIn: jest.fn(),
signOut: jest.fn(),
signUp: jest.fn(),
}));

jest.mock('@synonymdev/react-native-toast', () => ({ showToast: jest.fn() }));

jest.mock('../src/i18n', () => ({
__esModule: true,
default: { t: (key: string) => key },
}));

jest.mock('../src/store', () => ({ store: { dispatch: jest.fn() } }));

jest.mock('../src/store/slices/pubkysSlice', () => ({
addProcessing: (payload: unknown) => ({ type: 'pubky/addProcessing', payload }),
addPubky: (payload: unknown) => ({ type: 'pubky/addPubky', payload }),
addSession: (payload: unknown) => ({ type: 'pubky/addSession', payload }),
removeProcessing: (payload: unknown) => ({ type: 'pubky/removeProcessing', payload }),
removePubky: (payload: unknown) => ({ type: 'pubky/removePubky', payload }),
removeSession: (payload: unknown) => ({ type: 'pubky/removeSession', payload }),
setHomeserver: (payload: unknown) => ({ type: 'pubky/setHomeserver', payload }),
setPubkyData: (payload: unknown) => ({ type: 'pubky/setPubkyData', payload }),
setSignedUp: (payload: unknown) => ({ type: 'pubky/setSignedUp', payload }),
}));

jest.mock('../src/utils/helpers.ts', () => ({ checkNetworkConnection: jest.fn() }));

jest.mock('../src/utils/store-helpers.ts', () => ({
getPubkyDataFromStore: (...args: unknown[]) => mockGetPubkyDataFromStore(...args),
}));

jest.mock('../src/utils/keychain', () => ({
getAllKeychainKeys: (...args: unknown[]) => mockGetAllKeychainKeys(...args),
getKeychainValue: (...args: unknown[]) => mockGetKeychainValue(...args),
resetKeychainValue: (...args: unknown[]) => mockResetKeychainValue(...args),
setKeychainValue: (...args: unknown[]) => mockSetKeychainValue(...args),
}));

jest.mock('../src/utils/sharedPubky.ts', () => {
const normalize = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined;
const bare = value.startsWith('pubky') ? value.slice(5) : value;
return /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(bare) ? bare : undefined;
};

return {
BITKIT_SOURCE_APP: 'to.bitkit',
RING_SOURCE_APP: 'app.pubkyring',
getSharedPubkyCredential: jest.fn(),
isValidSharedSecretKey: (value: unknown) =>
typeof value === 'string' && /^[0-9a-f]{64}$/.test(value),
mirrorSharedPubky: (...args: unknown[]) => mockMirrorSharedPubky(...args),
normalizeSharedPubky: normalize,
privatePubkyService: (service: string) => {
const pubky = normalize(service);
return pubky ? { service, pubky } : undefined;
},
reconcileSharedPubkys: (...args: unknown[]) => mockReconcileSharedPubkys(...args),
removeSharedPubky: (...args: unknown[]) => mockRemoveSharedPubky(...args),
withPubkyIdentityLifecycle: (operation: () => Promise<unknown>) => operation(),
};
});

const ringPubky = (): Pubky => ({
name: '',
homeserver: '',
signedUp: false,
signupToken: '',
image: '',
sessions: [],
backupPreference: EBackupPreference.unknown,
isBackedUp: false,
sourceApp: 'app.pubkyring',
});

beforeEach(() => {
jest.clearAllMocks();
mockGetPublicKeyFromSecretKey.mockResolvedValue(ok({ public_key: OWNED }));
mockGetKeychainValue.mockResolvedValue(ok(JSON.stringify({ secretKey: SECRET, mnemonic: '' })));
mockSetKeychainValue.mockResolvedValue(ok('saved'));
mockResetKeychainValue.mockResolvedValue(ok(true));
mockGetAllKeychainKeys.mockResolvedValue([]);
mockGetPubkyDataFromStore.mockReturnValue(undefined);
mockMirrorSharedPubky.mockResolvedValue(true);
mockRemoveSharedPubky.mockResolvedValue(true);
mockReconcileSharedPubkys.mockResolvedValue(true);
});

test('re-imports an existing Ring identity instead of rejecting it as a duplicate', async () => {
mockGetPubkyDataFromStore.mockImplementation((pubky: string) => (pubky === OWNED ? ringPubky() : undefined));
mockGetAllKeychainKeys.mockResolvedValue([OWNED]);
const dispatch = jest.fn();

const result = await savePubky({
secretKey: SECRET,
pubky: OWNED,
dispatch,
isBackedUp: true,
backupPreference: EBackupPreference.encryptedFile,
});

expect(result.isOk()).toBe(true);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'pubky/setPubkyData',
payload: expect.objectContaining({ pubky: OWNED }),
}),
);
expect(mockMirrorSharedPubky).toHaveBeenCalledWith(OWNED, SECRET);
});

test('never promotes a Bitkit-owned identity into Ring private storage', async () => {
mockGetPubkyDataFromStore.mockReturnValue({ ...ringPubky(), sourceApp: 'to.bitkit' });
const dispatch = jest.fn();

const result = await savePubky({ secretKey: SECRET, pubky: OWNED, dispatch });

expect(result.isErr()).toBe(true);
expect(mockSetKeychainValue).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
});

test('rolls back a newly written private record when verification fails', async () => {
mockGetKeychainValue.mockResolvedValue(err(new Error('read failed')));
const dispatch = jest.fn();

const result = await savePubky({ secretKey: SECRET, pubky: OWNED, dispatch });

expect(result.isErr()).toBe(true);
expect(mockResetKeychainValue).toHaveBeenCalledWith({ key: OWNED });
expect(dispatch).not.toHaveBeenCalled();
});

test('does not prune shared mirrors after a private keychain read failure', async () => {
mockGetAllKeychainKeys.mockResolvedValue([OWNED]);
mockGetKeychainValue.mockResolvedValue(err(new Error('temporarily unavailable')));

await expect(reconcileOwnedSharedPubkys()).resolves.toBe(false);
expect(mockReconcileSharedPubkys).not.toHaveBeenCalled();
});

test('deletes every private service for a normalized identity before removing Redux state', async () => {
mockGetPubkyDataFromStore.mockImplementation((pubky: string) => (pubky === OWNED ? ringPubky() : undefined));
mockGetAllKeychainKeys.mockResolvedValue([OWNED, `pubky${OWNED}`]);
const dispatch = jest.fn();

const result = await deletePubky(`pk:${OWNED}`, dispatch);

expect(result.isOk()).toBe(true);
expect(mockRemoveSharedPubky).toHaveBeenCalledWith(OWNED);
expect(mockResetKeychainValue).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'pubky/removePubky', payload: OWNED }),
);
});

test('disconnects a Bitkit identity without deleting either key store', async () => {
mockGetPubkyDataFromStore.mockReturnValue({ ...ringPubky(), sourceApp: 'to.bitkit' });
const dispatch = jest.fn();

const result = await deletePubky(OWNED, dispatch);

expect(result.isOk()).toBe(true);
expect(mockRemoveSharedPubky).not.toHaveBeenCalled();
expect(mockResetKeychainValue).not.toHaveBeenCalled();
expect(mockGetAllKeychainKeys).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'pubky/removePubky', payload: OWNED }),
);
});
Loading
Loading