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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ expo-env.d.ts
npm-debug.*
yarn-debug.*
yarn-error.*
coverage/

# macOS
.DS_Store
Expand Down
30 changes: 30 additions & 0 deletions __tests__/__snapshots__/themed-test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`themed components matches the themed text snapshot 1`] = `
<View
style={
[
{
"backgroundColor": "#FFFFFF",
},
undefined,
]
}
>
<Text
style={
[
{
"color": "#121212",
"fontFamily": "Inter_600SemiBold",
"fontSize": 16,
"marginBottom": 0,
},
undefined,
]
}
>
Authenticator
</Text>
</View>
`;
177 changes: 177 additions & 0 deletions __tests__/challenge-polling-service-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
jest.mock("@/utils/rsa", () => ({
__esModule: true,
signMessage: jest.fn(),
verifyMessage: jest.fn(),
}));

import {
pollAllChallenges,
pollChallengesForToken,
} from "@/services/challengePollingService";
import { PushToken, PushTokenRolloutState } from "@/types";
import { base64ToBase32 } from "@/utils/crypto";
import { buildPushRequestSignedData } from "@/utils/pushRequestUtils";
import { signMessage, verifyMessage } from "@/utils/rsa";

const mockSignMessage = signMessage as jest.Mock;
const mockVerifyMessage = verifyMessage as jest.Mock;

const createToken = (overrides: Partial<PushToken> = {}): PushToken => ({
callbackUrl: "https://mfa.example.com/poll",
enrollmentCredential: "credential",
id: "PUSH0001",
label: "alice@example.com",
rolloutState: PushTokenRolloutState.Completed,
sslVerify: true,
ttl: 10,
version: 1,
...overrides,
});

const createChallenge = () => ({
nonce: "nonce-1",
question: "Allow login?",
serial: "PUSH0001",
signature: base64ToBase32("c2ln"),
sslverify: "1",
title: "Login",
url: "https://mfa.example.com/validate",
});

describe("challenge polling service", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, "error").mockImplementation(() => undefined);
jest.spyOn(console, "log").mockImplementation(() => undefined);
jest.spyOn(console, "warn").mockImplementation(() => undefined);
jest.spyOn(Date, "now").mockReturnValue(123_456);
mockSignMessage.mockResolvedValue("cG9sbC1zaWduYXR1cmU=");
mockVerifyMessage.mockResolvedValue(true);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: jest.fn().mockResolvedValue({
result: {
value: [createChallenge()],
},
}),
}) as jest.Mock;
});

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

test("polls a token and parses available challenges", async () => {
const token = createToken();

await expect(pollChallengesForToken(token)).resolves.toEqual({
success: true,
challenges: [
{
id: "poll-nonce-1-123456",
nonce: "nonce-1",
question: "Allow login?",
sentAt: 123_456,
serial: "PUSH0001",
signature: base64ToBase32("c2ln"),
sslverify: "1",
status: "pending",
title: "Login",
url: "https://mfa.example.com/validate",
},
],
});

const [url, options] = (globalThis.fetch as jest.Mock).mock.calls[0];
expect(url).toContain("https://mfa.example.com/poll?");
expect(url).toContain("serial=PUSH0001");
expect(url).toContain("signature=");
expect(options).toEqual({
method: "GET",
headers: { Accept: "application/json" },
});
});

test("verifies signed challenges when the token has a server public key", async () => {
const token = createToken({ serverPublicKey: "server-public-key" });
const challenge = createChallenge();

await pollChallengesForToken(token);

expect(mockVerifyMessage).toHaveBeenCalledWith(
buildPushRequestSignedData(challenge),
"c2ln",
"server-public-key",
);
});

test("filters challenges with invalid server signatures", async () => {
mockVerifyMessage.mockResolvedValueOnce(false);

await expect(
pollChallengesForToken(
createToken({ serverPublicKey: "server-public-key" }),
),
).resolves.toEqual({
success: true,
challenges: [],
});
});

test("treats 204 and 404 responses as empty successful polls", async () => {
(globalThis.fetch as jest.Mock).mockResolvedValueOnce({ status: 204 });
await expect(pollChallengesForToken(createToken())).resolves.toEqual({
success: true,
challenges: [],
});

(globalThis.fetch as jest.Mock).mockResolvedValueOnce({ status: 404 });
await expect(pollChallengesForToken(createToken())).resolves.toEqual({
success: true,
challenges: [],
});
});

test("returns server polling errors", async () => {
(globalThis.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 500,
text: jest.fn().mockResolvedValue("server error"),
});

await expect(pollChallengesForToken(createToken())).resolves.toMatchObject({
success: false,
challenges: [],
error: expect.any(Error),
});
});

test("polls only completed tokens and aggregates errors", async () => {
const completedToken = createToken({ id: "PUSH0001" });
const pendingToken = createToken({
id: "PUSH0002",
rolloutState: PushTokenRolloutState.Pending,
});

(globalThis.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 503,
text: jest.fn().mockResolvedValue("unavailable"),
});

await expect(
pollAllChallenges([completedToken, pendingToken]),
).resolves.toMatchObject({
success: false,
challenges: [],
error: expect.any(Error),
});
expect(globalThis.fetch).toHaveBeenCalledTimes(1);

await expect(pollAllChallenges([pendingToken])).resolves.toEqual({
success: true,
challenges: [],
});
});
});
26 changes: 26 additions & 0 deletions __tests__/crypto-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
base32ToBase64,
base64ToBase32,
stripPemArmor,
} from "@/utils/crypto";

describe("crypto utilities", () => {
test("strips PEM armor and whitespace", () => {
expect(
stripPemArmor(`
-----BEGIN PUBLIC KEY-----
abc
123
-----END PUBLIC KEY-----
`),
).toBe("abc123");
});

test("converts signatures between base64 and base32", () => {
const signatureBase64 = "YWJjMTIz";

expect(base32ToBase64(base64ToBase32(signatureBase64))).toBe(
signatureBase64,
);
});
});
50 changes: 50 additions & 0 deletions __tests__/locale-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
jest.mock("expo-localization", () => ({
__esModule: true,
getLocales: jest.fn(),
}));

jest.mock("@lingui/core", () => ({
__esModule: true,
i18n: {
loadAndActivate: jest.fn(),
},
}));

import { i18n } from "@lingui/core";
import { getLocales } from "expo-localization";

import { activateCurrentLocale, resolveLocale } from "@/utils/locale";

const mockGetLocales = getLocales as jest.Mock;
const mockLoadAndActivate = i18n.loadAndActivate as jest.Mock;

describe("locale utilities", () => {
beforeEach(() => {
jest.clearAllMocks();
});

test("uses supported device locales", () => {
mockGetLocales.mockReturnValue([{ languageCode: "DE" }]);

expect(resolveLocale()).toBe("de");
});

test("falls back to English for unsupported or missing locales", () => {
mockGetLocales.mockReturnValue([{ languageCode: "jp" }]);
expect(resolveLocale()).toBe("en");

mockGetLocales.mockReturnValue([]);
expect(resolveLocale()).toBe("en");
});

test("activates the resolved locale catalog", () => {
mockGetLocales.mockReturnValue([{ languageCode: "de" }]);

activateCurrentLocale();

expect(mockLoadAndActivate).toHaveBeenCalledWith({
locale: "de",
messages: expect.any(Object),
});
});
});
7 changes: 7 additions & 0 deletions __tests__/native-intent-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { redirectSystemPath } from "@/app/+native-intent";

describe("native intent redirect", () => {
test("keeps external deep links on the app root", () => {
expect(redirectSystemPath({ path: "/unknown", initial: true })).toBe("/");
});
});
Loading
Loading