Skip to content
Merged
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
60 changes: 60 additions & 0 deletions formulus/src/api/synkronus/Auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {synkronusApi} from './index';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Keychain from 'react-native-keychain';

export type UserRole = 'read-only' | 'read-write' | 'admin';

Expand Down Expand Up @@ -142,3 +143,62 @@ export const refreshToken = async () => {
await AsyncStorage.setItem('@tokenExpiresAt', expiresAt.toString());
return true;
};

/**
* Attempts to automatically re-login using stored credentials from Keychain.
* This is used when a 401 error is encountered during sync operations.
* @returns Promise<UserInfo> if login succeeds, null if credentials are not available
* @throws Error if login fails
*/
export const autoLogin = async (): Promise<UserInfo | null> => {
try {
// Get stored credentials from Keychain
const credentials = await Keychain.getGenericPassword();
if (!credentials || !credentials.username || !credentials.password) {
console.warn('No stored credentials found for auto-login');
return null;
}

console.log('🔄 Attempting auto-login with stored credentials');
const userInfo = await login(credentials.username, credentials.password);
console.log('✅ Auto-login successful - token refreshed');
return userInfo;
} catch (error: any) {
console.error('Auto-login failed:', error);
throw new Error(
`Auto-login failed: ${
error?.message || 'Unknown error'
}. Please login manually.`,
);
}
};

/**
* Checks if an error is a 401 Unauthorized error.
* Handles various error formats from Axios, fetch, and other HTTP clients.
*/
export const isUnauthorizedError = (error: any): boolean => {
if (!error) return false;

// Axios errors: error.response.status
if (error.response?.status === 401) return true;

// Direct status properties
if (error.status === 401 || error.statusCode === 401) return true;

// ProblemDetail format (from OpenAPI spec)
if (error.body?.status === 401 || error.data?.status === 401) return true;

// Check error message for 401 or unauthorized
if (typeof error.message === 'string') {
const msg = error.message.toLowerCase();
if (msg.includes('401') || msg.includes('unauthorized')) {
return true;
}
}

// Check error code
if (error.code === 'UNAUTHORIZED' || error.code === 401) return true;

return false;
};
227 changes: 227 additions & 0 deletions formulus/src/api/synkronus/__tests__/Auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* @format
*/

// Mock all native modules BEFORE any imports
jest.mock('react-native-keychain');
jest.mock('@react-native-async-storage/async-storage', () => ({
__esModule: true,
default: {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
multiRemove: jest.fn(),
},
}));
jest.mock('react-native-fs', () => ({
DocumentDirectoryPath: '/test/path',
exists: jest.fn(),
mkdir: jest.fn(),
unlink: jest.fn(),
readFile: jest.fn(),
writeFile: jest.fn(),
}));
jest.mock(
'../../database/DatabaseService',
() => ({
databaseService: {
getLocalRepo: jest.fn(),
},
}),
{virtual: true},
);
jest.mock(
'../../services/ClientIdService',
() => ({
clientIdService: {
getClientId: jest.fn().mockResolvedValue('test-client-id'),
},
}),
{virtual: true},
);
jest.mock('../index', () => ({
synkronusApi: {
getApi: jest.fn(),
clearTokenCache: jest.fn(),
},
}));

import {jest, describe, test, expect, beforeEach} from '@jest/globals';
import * as Keychain from 'react-native-keychain';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {autoLogin, isUnauthorizedError} from '../Auth';
import {synkronusApi} from '../index';

describe('Auth - Auto-Login', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('isUnauthorizedError', () => {
test('should detect Axios 401 error', () => {
const error = {
response: {status: 401},
};
expect(isUnauthorizedError(error)).toBe(true);
});

test('should detect direct status 401', () => {
const error = {status: 401};
expect(isUnauthorizedError(error)).toBe(true);
});

test('should detect statusCode 401', () => {
const error = {statusCode: 401};
expect(isUnauthorizedError(error)).toBe(true);
});

test('should detect ProblemDetail format 401', () => {
const error = {data: {status: 401}};
expect(isUnauthorizedError(error)).toBe(true);
});

test('should detect 401 in error message', () => {
const error = {message: 'Request failed with status code 401'};
expect(isUnauthorizedError(error)).toBe(true);
});

test('should detect "unauthorized" in error message', () => {
const error = {message: 'Unauthorized access'};
expect(isUnauthorizedError(error)).toBe(true);
});

test('should detect UNAUTHORIZED error code', () => {
const error = {code: 'UNAUTHORIZED'};
expect(isUnauthorizedError(error)).toBe(true);
});

test('should return false for non-401 errors', () => {
const error = {response: {status: 404}};
expect(isUnauthorizedError(error)).toBe(false);
});

test('should return false for null/undefined', () => {
expect(isUnauthorizedError(null)).toBe(false);
expect(isUnauthorizedError(undefined)).toBe(false);
});
});

describe('autoLogin', () => {
const mockCredentials = {
username: 'testuser',
password: 'testpass',
};

test('should successfully auto-login with stored credentials', async () => {
// Mock Keychain to return credentials
(Keychain.getGenericPassword as jest.Mock).mockResolvedValue(
mockCredentials,
);

// Mock AsyncStorage to return settings
(AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => {
if (key === '@settings') {
return Promise.resolve(
JSON.stringify({serverUrl: 'https://test.server'}),
);
}
return Promise.resolve(null);
});

// Mock the API and login response
const mockApi = {
login: jest.fn().mockResolvedValue({
data: {
token: 'new-token',
refreshToken: 'new-refresh-token',
expiresAt: Date.now() + 3600000,
},
}),
};
(synkronusApi.getApi as jest.Mock).mockResolvedValue(mockApi);
(synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined);
(AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined);

const result = await autoLogin();

expect(Keychain.getGenericPassword).toHaveBeenCalledTimes(1);
expect(mockApi.login).toHaveBeenCalledWith({
loginRequest: {
username: mockCredentials.username,
password: mockCredentials.password,
},
});
expect(result).toBeTruthy();
expect(result?.username).toBe(mockCredentials.username);
});

test('should return null when no credentials are stored', async () => {
(Keychain.getGenericPassword as jest.Mock).mockResolvedValue(false);

const result = await autoLogin();

expect(Keychain.getGenericPassword).toHaveBeenCalledTimes(1);
expect(synkronusApi.getApi).not.toHaveBeenCalled();
expect(result).toBeNull();
});

test('should return null when credentials have no username', async () => {
(Keychain.getGenericPassword as jest.Mock).mockResolvedValue({
password: 'testpass',
});

const result = await autoLogin();

expect(Keychain.getGenericPassword).toHaveBeenCalledTimes(1);
expect(synkronusApi.getApi).not.toHaveBeenCalled();
expect(result).toBeNull();
});

test('should return null when credentials have no password', async () => {
(Keychain.getGenericPassword as jest.Mock).mockResolvedValue({
username: 'testuser',
});

const result = await autoLogin();

expect(Keychain.getGenericPassword).toHaveBeenCalledTimes(1);
expect(synkronusApi.getApi).not.toHaveBeenCalled();
expect(result).toBeNull();
});

test('should throw error when login fails', async () => {
(Keychain.getGenericPassword as jest.Mock).mockResolvedValue(
mockCredentials,
);

const mockApi = {
login: jest.fn().mockRejectedValue(new Error('Invalid credentials')),
};
(synkronusApi.getApi as jest.Mock).mockResolvedValue(mockApi);
(synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined);

await expect(autoLogin()).rejects.toThrow(
'Auto-login failed: Invalid credentials. Please login manually.',
);

expect(Keychain.getGenericPassword).toHaveBeenCalledTimes(1);
expect(mockApi.login).toHaveBeenCalled();
});

test('should handle unknown error during login', async () => {
(Keychain.getGenericPassword as jest.Mock).mockResolvedValue(
mockCredentials,
);

const mockApi = {
login: jest.fn().mockRejectedValue({}),
};
(synkronusApi.getApi as jest.Mock).mockResolvedValue(mockApi);
(synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined);

await expect(autoLogin()).rejects.toThrow(
'Auto-login failed: Unknown error. Please login manually.',
);
});
});
});
3 changes: 3 additions & 0 deletions formulus/src/api/synkronus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,9 @@ class SynkronusApi {

public clearTokenCache(): void {
this.fastGetToken_cachedToken = null;
// Clear API instance to force recreation with new token after auto-login
this.api = null;
this.config = null;
}

private async downloadRawFiles(
Expand Down
2 changes: 1 addition & 1 deletion formulus/src/components/FormplayerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ const FormplayerModal = forwardRef<FormplayerModalHandle, FormplayerModalProps>(
};

// Load extensions for this form
let extensions: any = undefined;
let extensions: any;
try {
const customAppPath = RNFS.DocumentDirectoryPath + '/app';
const extensionService = ExtensionService.getInstance();
Expand Down
6 changes: 6 additions & 0 deletions formulus/src/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ const SettingsScreen = () => {

setIsLoggingIn(true);
try {
// Ensure server URL is saved before login (required by getApi())
await serverConfigService.saveServerUrl(trimmedUrl);

await Keychain.setGenericPassword(trimmedUsername, trimmedPassword);
await login(trimmedUsername, trimmedPassword);
ToastService.showShort('Successfully logged in!');
Expand Down Expand Up @@ -260,6 +263,9 @@ const SettingsScreen = () => {
setPassword(settings.password);

if (settings.username && settings.password) {
// Ensure server URL is saved before login (required by getApi())
await serverConfigService.saveServerUrl(settings.serverUrl);

await Keychain.setGenericPassword(
settings.username,
settings.password,
Expand Down
Loading
Loading