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
42 changes: 42 additions & 0 deletions __tests__/rntl/screens/SignInScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,48 @@ describe('SignInScreen', () => {
expect(mockGoBack).not.toHaveBeenCalled();
});

// The spinner used to be cleared on each exit path rather than in a
// `finally`. A profile call that never settled -- ganeshaApiClient had no
// request timeout -- left the button spinning forever, with no error and no
// way to retry. In the field that looked like the app had simply frozen.

it('clears the spinner after a failed profile lookup so sign-in can be retried', async () => {
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
mockSignIn.mockResolvedValue({ accessToken: 'a', refreshToken: 'r', idToken: 'i', accessTokenExpirationDate: '' });
mockGetUserProfile.mockResolvedValue({ ok: false, code: 'timeout', message: 'Request timed out after 30s' });

const { getByTestId, queryByText } = render(<SignInScreen />);
fireEvent.press(getByTestId('sign-in-button'));

await waitFor(() => expect(Alert.alert).toHaveBeenCalled());
// The mocked Button renders "<title> (loading)" while its spinner shows.
expect(queryByText(/\(loading\)/)).toBeNull();
});

it('clears the spinner after a cancelled sign-in', async () => {
mockSignIn.mockRejectedValue(new Error('User cancelled flow'));

const { getByTestId, queryByText } = render(<SignInScreen />);
fireEvent.press(getByTestId('sign-in-button'));

await waitFor(() => expect(mockSignIn).toHaveBeenCalled());
await waitFor(() => expect(queryByText(/\(loading\)/)).toBeNull());
});

it('says the session was saved when only the profile lookup failed', async () => {
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
mockSignIn.mockResolvedValue({ accessToken: 'a', refreshToken: 'r', idToken: 'i', accessTokenExpirationDate: '' });
mockGetUserProfile.mockResolvedValue({ ok: false, code: 'timeout', message: 'Request timed out after 30s' });

const { getByTestId } = render(<SignInScreen />);
fireEvent.press(getByTestId('sign-in-button'));

await waitFor(() => expect(alertSpy).toHaveBeenCalled());
const [title, body] = alertSpy.mock.calls[0];
expect(title).not.toMatch(/sign-in failed/i);
expect(body).toMatch(/session is saved/i);
});

it('alerts on a real sign-in failure but not on a cancelled sign-in', async () => {
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
mockSignIn.mockRejectedValue(new Error('User cancelled flow'));
Expand Down
25 changes: 24 additions & 1 deletion __tests__/unit/configureApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,27 @@ describe('deployment configuration', () => {
configureApp(options);
expect(fs.statSync(generated).mtimeMs).toBe(0);
});
});
});

describe('configureApp redirect normalisation', () => {
// Entra returns a custom-scheme redirect with a trailing slash appended.
// AppAuth-iOS compares the callback path against the configured redirect, so
// storing it without the slash makes iOS reject its own callback and hang.
// Android matches on scheme alone and is unaffected either way.
it('stores the redirect exactly as Entra returns it, with the trailing slash', () => {
const config = validateConfig({ ...example, redirectUrl: 'org.ganesha.elebook://oauthredirect/' });

expect(config.redirectUrl).toBe('org.ganesha.elebook://oauthredirect/');
});

it('normalises the pre-fix spelling so existing configs self-heal', () => {
const config = validateConfig({ ...example, redirectUrl: 'org.ganesha.elebook://oauthredirect' });

expect(config.redirectUrl).toBe('org.ganesha.elebook://oauthredirect/');
});

it('still rejects a redirect belonging to a different app', () => {
expect(() => validateConfig({ ...example, redirectUrl: 'different.app://oauthredirect/' }))
.toThrow(/redirectUrl/);
});
});
10 changes: 8 additions & 2 deletions __tests__/unit/deploymentConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const LONG_DEPLOYMENT = {
tenantId: '77777777-7777-4777-8777-777777777777',
mobileClientId: '88888888-8888-4888-8888-888888888888',
apiClientId: '99999999-9999-4999-8999-999999999999',
redirectUrl: 'org.ganesha.elebook://oauthredirect',
redirectUrl: 'org.ganesha.elebook://oauthredirect/',
};

describe('deployment token storage', () => {
Expand All @@ -40,8 +40,14 @@ describe('deployment token storage', () => {

it('derives the documented service name for the example deployment', () => {
// Pinned on purpose: changing the derivation orphans tokens stored by earlier builds.
//
// This value moved once, when redirectUrl gained the trailing slash that
// iOS needs to accept its own OAuth callback. Anyone already signed in is
// signed out by that change and has to sign in again; observations, packs
// and the local database are untouched. iOS loses nothing, never having
// been able to sign in at all.
expect(getTokenStorageService(example)).toBe(
'org.ganesha.elebook.entra.92eda79296364b345b9cb882a37983eb15de4e4ac4d7c6aa511b6d2d5ed543ed',
'org.ganesha.elebook.entra.df2facb18aac60dd0a72437d48a060371a0843065ae7fa0383d7a47f1d1a355b',
);
});

Expand Down
59 changes: 58 additions & 1 deletion __tests__/unit/services/entraAuthService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ jest.mock('react-native-keychain', () => ({

import { authorize, refresh, revoke } from 'react-native-app-auth';
import * as Keychain from 'react-native-keychain';
import { entraAuthService } from '../../../src/services/entraAuthService';
import {
entraAuthService,
ENTRA_INTERACTIVE_TIMEOUT_MS,
ENTRA_REFRESH_TIMEOUT_MS,
} from '../../../src/services/entraAuthService';
import { ENTRA_ISSUER, ENTRA_MOBILE_CLIENT_ID, ENTRA_REDIRECT_URL, ENTRA_SCOPES } from '../../../src/config/entraAuth';
import { deploymentConfig, getTokenStorageService } from '../../../src/config/deployment';

Expand Down Expand Up @@ -234,3 +238,56 @@ describe('entraAuthService.getValidAccessToken', () => {
expect(mockResetGenericPassword).toHaveBeenCalledWith({ service: TOKEN_SERVICE });
});
});

describe('entraAuthService deadlines', () => {
afterEach(() => {
jest.useRealTimers();
});

// AppAuth bounds neither leg. On a marginal link the token exchange stalls
// after the browser has already closed, so authorize() never settles and the
// sign-in screen spins forever with nothing to report.
it('gives up on an interactive sign-in that never settles', async () => {
jest.useFakeTimers();
mockAuthorize.mockImplementation(() => new Promise(() => {}));

const pending = entraAuthService.signIn();
const assertion = (async () => {
await expect(pending).rejects.toThrow(/timed out after 180s/);
})();
await jest.advanceTimersByTimeAsync(ENTRA_INTERACTIVE_TIMEOUT_MS);
await assertion;
});

it('gives up on a token refresh that never settles', async () => {
jest.useFakeTimers();
mockGetGenericPassword.mockResolvedValue({
username: 'entra-tokens',
password: JSON.stringify({
accessToken: 'stale',
refreshToken: 'refresh-me',
idToken: 'id',
accessTokenExpirationDate: new Date(Date.now() - 1000).toISOString(),
}),
});
mockRefresh.mockImplementation(() => new Promise(() => {}));

const pending = entraAuthService.getValidAccessToken();
await jest.advanceTimersByTimeAsync(ENTRA_REFRESH_TIMEOUT_MS);

// A refresh that cannot complete is reported as "no valid session" rather
// than thrown, matching how an expired refresh token is already handled.
await expect(pending).resolves.toBeNull();
});

it('does not interfere with a sign-in that completes normally', async () => {
mockAuthorize.mockResolvedValue({
accessToken: 'a',
refreshToken: 'r',
idToken: 'i',
accessTokenExpirationDate: new Date(Date.now() + 3_600_000).toISOString(),
});

await expect(entraAuthService.signIn()).resolves.toMatchObject({ accessToken: 'a' });
});
});
138 changes: 138 additions & 0 deletions __tests__/unit/services/fileDownloadService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
jest.mock('react-native-fs', () => ({
mkdir: jest.fn(() => Promise.resolve()),
unlink: jest.fn(() => Promise.resolve()),
stat: jest.fn(() => Promise.resolve({ size: 1000 })),
hash: jest.fn(() => Promise.resolve('a'.repeat(64))),
moveFile: jest.fn(() => Promise.resolve()),
downloadFile: jest.fn(),
stopDownload: jest.fn(),
}));

import RNFS from 'react-native-fs';
import { downloadFileWithIntegrityCheck } from '../../../src/services/fileDownloadService';

const mockDownloadFile = RNFS.downloadFile as jest.Mock;
const mockStopDownload = RNFS.stopDownload as jest.Mock;

const target = {
source: {
url: 'https://example.org/model.onnx',
expectedSha256: 'a'.repeat(64),
expectedSizeBytes: 1000,
},
stagingPath: '/mock/staging/model.onnx.part',
finalPath: '/mock/models/model.onnx',
};

describe('fileDownloadService inactivity timeout', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('stops and reports a download that receives no data before the deadline', async () => {
mockDownloadFile.mockReturnValue({
jobId: 42,
promise: new Promise(() => {}),
});

const pending = downloadFileWithIntegrityCheck(target, {
maxAttempts: 1,
inactivityTimeoutMs: 1000,
});
await jest.advanceTimersByTimeAsync(1000);

await expect(pending).resolves.toMatchObject({
ok: false,
code: 'timeout',
message: 'download received no data for 1s',
});
expect(mockStopDownload).toHaveBeenCalledWith(42);
expect(mockDownloadFile).toHaveBeenCalledWith(
expect.objectContaining({
progressInterval: 1000,
readTimeout: 1000,
}),
);
});

it('resets the deadline when download progress arrives', async () => {
let reportProgress: (() => void) | undefined;
let resolveDownload:
| ((result: { statusCode: number; bytesWritten: number }) => void)
| undefined;
mockDownloadFile.mockImplementation(
(options: {
progress?: (result: {
bytesWritten: number;
contentLength: number;
}) => void;
}) => {
reportProgress = () =>
options.progress?.({ bytesWritten: 500, contentLength: 1000 });
return {
jobId: 42,
promise: new Promise(resolve => {
resolveDownload = resolve;
}),
};
},
);

const pending = downloadFileWithIntegrityCheck(target, {
maxAttempts: 1,
inactivityTimeoutMs: 1000,
});
await jest.advanceTimersByTimeAsync(750);
reportProgress?.();
await jest.advanceTimersByTimeAsync(750);
expect(mockStopDownload).not.toHaveBeenCalled();

resolveDownload?.({ statusCode: 200, bytesWritten: 1000 });
await expect(pending).resolves.toMatchObject({ ok: true });
});
});

describe('background transfer', () => {
beforeEach(() => {
jest.clearAllMocks();
});

// A foreground URLSession stops when iOS suspends the app, so locking the
// screen part-way through an 80MB model killed the transfer. Upstream had
// already removed the foreground path ("use background downloads
// exclusively"); this service was rewritten without the flag while
// AppDelegate kept servicing handleEventsForBackgroundURLSession.
it('asks for a background session so a locked screen does not kill the transfer', async () => {
mockDownloadFile.mockReturnValue({
jobId: 1,
promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }),
});

await downloadFileWithIntegrityCheck(target);

expect(mockDownloadFile).toHaveBeenCalledWith(
expect.objectContaining({ background: true }),
);
});

it('re-arms the inactivity deadline when the app returns to the foreground', async () => {
const { AppState } = require('react-native');
const addEventListener = jest.spyOn(AppState, 'addEventListener');
mockDownloadFile.mockReturnValue({
jobId: 1,
promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }),
});

await downloadFileWithIntegrityCheck(target);

// Suspended JS timers fire late on wake; without this the watchdog would
// trip against a transfer that progressed fine while backgrounded.
expect(addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
addEventListener.mockRestore();
});
});
50 changes: 49 additions & 1 deletion __tests__/unit/services/ganeshaApiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ jest.mock('../../../src/services/entraAuthService', () => ({
entraAuthService: { getValidAccessToken: jest.fn() },
}));

import { ganeshaApiClient } from '../../../src/services/ganeshaApiClient';
import { ganeshaApiClient, GANESHA_REQUEST_TIMEOUT_MS } from '../../../src/services/ganeshaApiClient';
import { entraAuthService } from '../../../src/services/entraAuthService';

const mockGetValidAccessToken = entraAuthService.getValidAccessToken as jest.Mock;
Expand Down Expand Up @@ -310,3 +310,51 @@ describe('ganeshaApiClient.createUserProfile', () => {
);
});
});

describe('ganeshaApiClient request deadline', () => {
afterEach(() => {
jest.useRealTimers();
});

// Without a deadline a connection that is accepted but never answered leaves
// the promise pending forever, which the screens render as a permanent
// spinner with no error -- the iOS field symptom this guards against.
it('aborts and reports a timeout when the server never answers', async () => {
jest.useFakeTimers();
mockFetch.mockImplementation(
(_url: string, init: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
}),
);

const pending = ganeshaApiClient.getUserProfile();
// Async advance: the request awaits getValidAccessToken() before it ever
// arms the deadline, so a synchronous advance would fire against a timer
// that does not exist yet and the promise would hang.
await jest.advanceTimersByTimeAsync(GANESHA_REQUEST_TIMEOUT_MS);
const result = await pending;

expect(result.ok).toBe(false);
expect(result).toMatchObject({ code: 'timeout' });
});

it('passes an abort signal on every request', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(200, {}));

await ganeshaApiClient.getUserProfile();

expect(mockFetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ signal: expect.anything() }),
);
});

it('still reports a plain network error as network-error, not a timeout', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network request failed'));

const result = await ganeshaApiClient.getUserProfile();

expect(result).toMatchObject({ code: 'network-error', message: 'Network request failed' });
});
});
Loading
Loading