diff --git a/core/api/galoy.yaml b/core/api/galoy.yaml index abc749fe64..b1703c3593 100644 --- a/core/api/galoy.yaml +++ b/core/api/galoy.yaml @@ -30,6 +30,7 @@ admin_accounts: - role: bankowner phone: "+16505554334" test_accounts: [] +test_accounts_captcha: [] rateLimits: requestCodePerEmail: points: 4 diff --git a/core/api/src/app/authentication/request-code.ts b/core/api/src/app/authentication/request-code.ts index eed18bf85c..f76356f4c7 100644 --- a/core/api/src/app/authentication/request-code.ts +++ b/core/api/src/app/authentication/request-code.ts @@ -4,6 +4,7 @@ import { getAccountsOnboardConfig, getGeetestConfig, getTestAccounts, + getTestAccountsCaptcha, } from "@/config" import { ErrorLevel } from "@/domain/shared" import { ChannelType, checkedToChannel } from "@/domain/phone-provider" @@ -49,15 +50,25 @@ export const requestPhoneCodeWithCaptcha = async ({ ip: IpAddress channel: string }): Promise => { - 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) diff --git a/core/api/src/config/schema.ts b/core/api/src/config/schema.ts index 88ea771afa..667e68735e 100644 --- a/core/api/src/config/schema.ts +++ b/core/api/src/config/schema.ts @@ -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: { @@ -1059,6 +1072,7 @@ export const configSchema = { "quizzes", "admin_accounts", "test_accounts", + "test_accounts_captcha", "rateLimits", "accounts", "accountLimits", diff --git a/core/api/src/config/schema.types.d.ts b/core/api/src/config/schema.types.d.ts index 7a9f1518b3..1a5a4a6ea7 100644 --- a/core/api/src/config/schema.types.d.ts +++ b/core/api/src/config/schema.types.d.ts @@ -192,6 +192,9 @@ type YamlSchema = { phone: string code: string }[] + test_accounts_captcha: { + phone: string + }[] rateLimits: { requestCodePerEmail: RateLimitInput requestCodePerPhoneNumber: RateLimitInput diff --git a/core/api/src/config/yaml.ts b/core/api/src/config/yaml.ts index bfff7a7e50..2274688a12 100644 --- a/core/api/src/config/yaml.ts +++ b/core/api/src/config/yaml.ts @@ -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 diff --git a/core/api/test/unit/app/auth/request-code-captcha-bypass.spec.ts b/core/api/test/unit/app/auth/request-code-captcha-bypass.spec.ts new file mode 100644 index 0000000000..0bda3f310d --- /dev/null +++ b/core/api/test/unit/app/auth/request-code-captcha-bypass.spec.ts @@ -0,0 +1,225 @@ +import { CaptchaUserFailToPassError } from "@/domain/captcha/errors" + +// Mock functions declared at module scope — jest.fn() calls are hoisted. +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", + ) + }) + }) +}) diff --git a/core/api/test/unit/app/auth/test-accounts-captcha.spec.ts b/core/api/test/unit/app/auth/test-accounts-captcha.spec.ts new file mode 100644 index 0000000000..0f148da876 --- /dev/null +++ b/core/api/test/unit/app/auth/test-accounts-captcha.spec.ts @@ -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) + }) +})