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 core/api/galoy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ admin_accounts:
- role: bankowner
phone: "+16505554334"
test_accounts: []
test_accounts_captcha: []
rateLimits:
requestCodePerEmail:
points: 4
Expand Down
27 changes: 19 additions & 8 deletions core/api/src/app/authentication/request-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getAccountsOnboardConfig,
getGeetestConfig,
getTestAccounts,
getTestAccountsCaptcha,
} from "@/config"
import { ErrorLevel } from "@/domain/shared"
import { ChannelType, checkedToChannel } from "@/domain/phone-provider"
Expand Down Expand Up @@ -49,15 +50,25 @@ export const requestPhoneCodeWithCaptcha = async ({
ip: IpAddress
channel: string
}): Promise<true | ApplicationError> => {
const geeTestConfig = getGeetestConfig()
const geetest = Geetest(geeTestConfig)

const verifySuccess = await geetest.validate(
geetestChallenge,
geetestValidate,
geetestSeccode,
const testAccountsCaptcha = getTestAccountsCaptcha()
const isCaptchaTestAccount = testAccountsCaptcha.some(
(account) => account.phone === phone,
)
if (verifySuccess instanceof Error) return verifySuccess

if (isCaptchaTestAccount) {
baseLogger.info({ phone }, "Skipping CAPTCHA validation for test account")
addAttributesToCurrentSpan({ "requestCode.captchaSkipped": true })
} else {
const geeTestConfig = getGeetestConfig()
const geetest = Geetest(geeTestConfig)

const verifySuccess = await geetest.validate(
geetestChallenge,
geetestValidate,
geetestSeccode,
)
if (verifySuccess instanceof Error) return verifySuccess
}

{
const limitOk = await checkRequestCodeAttemptPerIpLimits(ip)
Expand Down
14 changes: 14 additions & 0 deletions core/api/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,19 @@ export const configSchema = {
default: [],
uniqueItems: true,
},
test_accounts_captcha: {
type: "array",
items: {
type: "object",
properties: {
phone: { type: "string" },
},
required: ["phone"],
additionalProperties: false,
},
default: [],
uniqueItems: true,
},
rateLimits: {
type: "object",
properties: {
Expand Down Expand Up @@ -1059,6 +1072,7 @@ export const configSchema = {
"quizzes",
"admin_accounts",
"test_accounts",
"test_accounts_captcha",
"rateLimits",
"accounts",
"accountLimits",
Expand Down
3 changes: 3 additions & 0 deletions core/api/src/config/schema.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ type YamlSchema = {
phone: string
code: string
}[]
test_accounts_captcha: {
phone: string
}[]
rateLimits: {
requestCodePerEmail: RateLimitInput
requestCodePerPhoneNumber: RateLimitInput
Expand Down
5 changes: 5 additions & 0 deletions core/api/src/config/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,11 @@ export const getTestAccounts = (config = yamlConfig): TestAccount[] =>
code: account.code as PhoneCode,
}))

export const getTestAccountsCaptcha = (config = yamlConfig): { phone: PhoneNumber }[] =>
config.test_accounts_captcha.map((account) => ({
phone: account.phone as PhoneNumber,
}))

export const getCronConfig = (config = yamlConfig): CronConfig => config.cronConfig

export const getCaptcha = (config = yamlConfig): CaptchaConfig => config.captcha
Expand Down
225 changes: 225 additions & 0 deletions core/api/test/unit/app/auth/request-code-captcha-bypass.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { CaptchaUserFailToPassError } from "@/domain/captcha/errors"

// Mock functions declared at module scope — jest.fn() calls are hoisted.
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment about hoisting is misleading: Jest hoists jest.mock() calls, but jest.fn() declarations are not hoisted. Consider rewording to avoid confusion (e.g., explain that the mock factory is hoisted and therefore must not reference later-initialized variables).

Suggested change
// Mock functions declared at module scope jest.fn() calls are hoisted.
// Mock functions declared at module scope so hoisted jest.mock() factories can reference them.

Copilot uses AI. Check for mistakes.
const mockGetTestAccountsCaptcha = jest.fn()
const mockGetGeetestConfig = jest.fn()
const mockGetTestAccounts = jest.fn()
const mockGetAccountsOnboardConfig = jest.fn()
const mockGeetestValidate = jest.fn()
const mockConsumeLimiter = jest.fn()
const mockAddAttributesToCurrentSpan = jest.fn()
const mockRecordExceptionInCurrentSpan = jest.fn()

jest.mock("@/config", () => {
// Inline rate limit defaults — cannot reference outer const due to jest.mock hoisting
const rl = { points: 10, duration: 3600, blockDuration: 3600 }
return {
getTestAccountsCaptcha: (...args: unknown[]) => mockGetTestAccountsCaptcha(...args),
getGeetestConfig: (...args: unknown[]) => mockGetGeetestConfig(...args),
getTestAccounts: (...args: unknown[]) => mockGetTestAccounts(...args),
getAccountsOnboardConfig: (...args: unknown[]) =>
mockGetAccountsOnboardConfig(...args),
getRequestCodePerEmailLimits: () => rl,
getRequestCodePerPhoneNumberLimits: () => rl,
getRequestCodePerIpLimits: () => rl,
getRequestTelegramPassportNoncePerPhoneNumberLimits: () => rl,
getRequestTelegramPassportNoncePerIpLimits: () => rl,
getLoginAttemptPerLoginIdentifierLimits: () => rl,
getFailedLoginAttemptPerIpLimits: () => rl,
getInvoiceCreateAttemptLimits: () => rl,
getInvoiceCreateForRecipientAttemptLimits: () => rl,
getOnChainAddressCreateAttemptLimits: () => rl,
getDeviceAccountCreateAttemptLimits: () => rl,
getAppcheckJtiAttemptLimits: () => rl,
getAddQuizPerIpLimits: () => rl,
getAddQuizPerPhoneLimits: () => rl,
getSmsAuthUnsupportedCountries: () => [],
getWhatsAppAuthUnsupportedCountries: () => [],
getTelegramAuthUnsupportedCountries: () => [],
TWILIO_ACCOUNT_SID: "test-sid",
UNSECURE_DEFAULT_LOGIN_CODE: undefined,
}
})

jest.mock("@/services/geetest", () => ({
__esModule: true,
default: jest.fn(() => ({
validate: (...args: unknown[]) => mockGeetestValidate(...args),
})),
}))

jest.mock("@/services/rate-limit", () => ({
consumeLimiter: (...args: unknown[]) => mockConsumeLimiter(...args),
}))

jest.mock("@/services/mongoose", () => ({
UsersRepository: jest.fn(() => ({
findByPhone: jest.fn(() => new Error("not found")),
})),
}))

jest.mock("@/services/phone-provider", () => ({
getPhoneProviderVerifyService: jest.fn(() => ({
initiateVerify: jest.fn(() => true),
})),
}))

jest.mock("@/services/phone-provider/twilio-service", () => ({
TWILIO_ACCOUNT_TEST: "twilio-test-sid",
}))

jest.mock("@/services/ipfetcher", () => ({
IpFetcher: jest.fn(() => ({
fetchIPInfo: jest.fn(() => ({})),
})),
}))

jest.mock("@/services/logger", () => ({
__esModule: true,
baseLogger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}))

jest.mock("@/services/tracing", () => ({
addAttributesToCurrentSpan: (...args: unknown[]) =>
mockAddAttributesToCurrentSpan(...args),
recordExceptionInCurrentSpan: (...args: unknown[]) =>
mockRecordExceptionInCurrentSpan(...args),
}))

jest.mock("@/services/kratos", () => ({
AuthWithEmailPasswordlessService: jest.fn(),
}))

import { requestPhoneCodeWithCaptcha } from "@/app/authentication/request-code"
import { baseLogger } from "@/services/logger"

const testPhone = "+16505551234" as PhoneNumber
const otherPhone = "+16505559999" as PhoneNumber
const testIp = "127.0.0.1" as IpAddress

const baseCaptchaArgs = {
geetestChallenge: "test-challenge",
geetestValidate: "test-validate",
geetestSeccode: "test-seccode",
ip: testIp,
channel: "sms",
}

beforeEach(() => {
jest.clearAllMocks()

mockGetGeetestConfig.mockReturnValue({ id: "test-id", key: "test-key" })
mockGetTestAccounts.mockReturnValue([])
mockGetTestAccountsCaptcha.mockReturnValue([])
mockGetAccountsOnboardConfig.mockReturnValue({
phoneMetadataValidationSettings: { enabled: false },
ipMetadataValidationSettings: { enabled: false },
})
mockConsumeLimiter.mockResolvedValue(true)
mockGeetestValidate.mockResolvedValue(true)
})

describe("requestPhoneCodeWithCaptcha - CAPTCHA bypass for test accounts", () => {
describe("when phone IS in test_accounts_captcha", () => {
beforeEach(() => {
mockGetTestAccountsCaptcha.mockReturnValue([{ phone: testPhone }])
})

it("skips CAPTCHA validation and does not call Geetest.validate", async () => {
const result = await requestPhoneCodeWithCaptcha({
phone: testPhone,
...baseCaptchaArgs,
})

expect(result).not.toBeInstanceOf(Error)
expect(mockGeetestValidate).not.toHaveBeenCalled()
})

it("proceeds to rate limiting after skipping CAPTCHA", async () => {
await requestPhoneCodeWithCaptcha({
phone: testPhone,
...baseCaptchaArgs,
})

expect(mockConsumeLimiter).toHaveBeenCalled()
})

it("logs that CAPTCHA is being skipped", async () => {
await requestPhoneCodeWithCaptcha({
phone: testPhone,
...baseCaptchaArgs,
})

expect(baseLogger.info).toHaveBeenCalledWith(
{ phone: testPhone },
"Skipping CAPTCHA validation for test account",
)
})

it("sets tracing attributes when CAPTCHA is skipped", async () => {
await requestPhoneCodeWithCaptcha({
phone: testPhone,
...baseCaptchaArgs,
})

expect(mockAddAttributesToCurrentSpan).toHaveBeenCalledWith({
"requestCode.captchaSkipped": true,
})
})
})

describe("when phone is NOT in test_accounts_captcha", () => {
beforeEach(() => {
mockGetTestAccountsCaptcha.mockReturnValue([{ phone: otherPhone }])
})

it("validates CAPTCHA normally via Geetest.validate", async () => {
await requestPhoneCodeWithCaptcha({
phone: testPhone,
...baseCaptchaArgs,
})

expect(mockGeetestValidate).toHaveBeenCalledWith(
"test-challenge",
"test-validate",
"test-seccode",
)
})

it("returns error when CAPTCHA validation fails", async () => {
const captchaError = new CaptchaUserFailToPassError()
mockGeetestValidate.mockResolvedValue(captchaError)

const result = await requestPhoneCodeWithCaptcha({
phone: testPhone,
...baseCaptchaArgs,
})

expect(result).toBeInstanceOf(CaptchaUserFailToPassError)
})
})

describe("when test_accounts_captcha is empty", () => {
beforeEach(() => {
mockGetTestAccountsCaptcha.mockReturnValue([])
})

it("validates CAPTCHA normally via Geetest.validate", async () => {
await requestPhoneCodeWithCaptcha({
phone: testPhone,
...baseCaptchaArgs,
})

expect(mockGeetestValidate).toHaveBeenCalledWith(
"test-challenge",
"test-validate",
"test-seccode",
)
})
})
})
36 changes: 36 additions & 0 deletions core/api/test/unit/app/auth/test-accounts-captcha.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getTestAccountsCaptcha } from "@/config/yaml"

describe("getTestAccountsCaptcha", () => {
it("returns phone numbers from config", () => {
const config = {
test_accounts_captcha: [{ phone: "+16505551234" }, { phone: "+16505555678" }],
} as unknown as YamlSchema

const result = getTestAccountsCaptcha(config)

expect(result).toEqual([{ phone: "+16505551234" }, { phone: "+16505555678" }])
expect(result).toHaveLength(2)
})

it("returns empty array when config has no test accounts", () => {
const config = {
test_accounts_captcha: [],
} as unknown as YamlSchema

const result = getTestAccountsCaptcha(config)

expect(result).toEqual([])
expect(result).toHaveLength(0)
})

it("returns single phone number from config", () => {
const config = {
test_accounts_captcha: [{ phone: "+16505551111" }],
} as unknown as YamlSchema

const result = getTestAccountsCaptcha(config)

expect(result).toEqual([{ phone: "+16505551111" }])
expect(result).toHaveLength(1)
})
})
Loading