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
73 changes: 73 additions & 0 deletions __tests__/hooks/use-account-upgrade-content-type.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* ENG-291 — Bug: HTTP 400 on account upgrade Step 4 (ID document upload)
*
* Root cause: Android `react-native-image-picker` returns `image/jpg` for some
* camera-captured photos. The backend storage service only accepts:
* ["image/jpeg", "image/png", "image/webp"]
*
* `image/jpg` is NOT in the backend allowlist, causing `InvalidFileTypeError`
* → upload URL not generated → submission fails with HTTP 400.
*
* Fix: normalize `image/jpg` → `image/jpeg` before calling the upload mutation.
*/

import { normalizeContentType, isValidContentType } from "@app/utils/image-content-type"

describe("normalizeContentType (ENG-291)", () => {
it("normalizes image/jpg to image/jpeg", () => {
expect(normalizeContentType("image/jpg")).toBe("image/jpeg")
})

it("leaves image/jpeg unchanged", () => {
expect(normalizeContentType("image/jpeg")).toBe("image/jpeg")
})

it("leaves image/png unchanged", () => {
expect(normalizeContentType("image/png")).toBe("image/png")
})

it("leaves image/webp unchanged", () => {
expect(normalizeContentType("image/webp")).toBe("image/webp")
})

it("leaves unknown types unchanged (backend will reject)", () => {
expect(normalizeContentType("image/heic")).toBe("image/heic")
})
})

describe("isValidContentType (ENG-291 — backend-compatible)", () => {
const BACKEND_ALLOWED = ["image/jpeg", "image/png", "image/webp"]

it("accepts image/jpeg", () => {
expect(isValidContentType("image/jpeg")).toBe(true)
})

it("accepts image/png", () => {
expect(isValidContentType("image/png")).toBe(true)
})

it("accepts image/webp", () => {
expect(isValidContentType("image/webp")).toBe(true)
})

it("rejects image/jpg (non-normalized — was causing the bug)", () => {
// Before the fix, image/jpg was in ALLOWED_FILE_TYPES client-side
// but not in the backend allowlist — this test documents that we no longer
// send image/jpg raw; we normalize first
expect(isValidContentType("image/jpg")).toBe(false)
})

it("rejects image/heic", () => {
expect(isValidContentType("image/heic")).toBe(false)
})

it("rejects empty string", () => {
expect(isValidContentType("")).toBe(false)
})

it("all backend-allowed types pass", () => {
BACKEND_ALLOWED.forEach((type) => {
expect(isValidContentType(type)).toBe(true)
})
})
})
13 changes: 8 additions & 5 deletions app/components/account-upgrade-flow/PhotoUploadField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"
import { Alert, Image, Linking, Modal, TouchableOpacity, View } from "react-native"
import { Icon, makeStyles, Text, useTheme } from "@rneui/themed"
import { Asset, launchImageLibrary } from "react-native-image-picker"
import { normalizeContentType, isValidContentType } from "@app/utils/image-content-type"
import {
Camera,
CameraRuntimeError,
Expand All @@ -24,7 +25,6 @@ import PhotoAdd from "@app/assets/icons/photo-add.svg"
import { toastShow } from "@app/utils/toast"

const MAX_FILE_SIZE = 5 * 1024 * 1024 // File size limit in bytes (5MB)
const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/jpg"]

type Props = {
label: string
Expand Down Expand Up @@ -89,12 +89,13 @@ const PhotoUploadField: React.FC<Props> = ({
result.assets[0].type &&
result.assets[0].fileSize
) {
if (!ALLOWED_FILE_TYPES.includes(result.assets[0].type)) {
const normalizedType = normalizeContentType(result.assets[0].type)
if (!isValidContentType(normalizedType)) {
setErrorMsg("Please upload a valid image (JPG or PNG)")
} else if (result?.assets[0]?.fileSize > MAX_FILE_SIZE) {
setErrorMsg("File size exceeds 5MB limit")
} else {
onPhotoUpload(result.assets[0])
onPhotoUpload({ ...result.assets[0], type: normalizedType })
}
}
} catch (err: unknown) {
Expand All @@ -111,14 +112,16 @@ const PhotoUploadField: React.FC<Props> = ({
const result = await fetch(`file://${file?.path}`)
const data = await result.blob()

if (!ALLOWED_FILE_TYPES.includes(data.type)) {
// Normalize content type before validation — camera can return "image/jpg" or ""
const normalizedType = normalizeContentType(data.type || "image/jpeg")
if (!isValidContentType(normalizedType)) {
setErrorMsg("Please upload a valid image (JPG or PNG)")
} else if (data.size > MAX_FILE_SIZE) {
setErrorMsg("File size exceeds 5MB limit")
} else {
onPhotoUpload({
uri: `file://${file?.path}`,
type: data.type,
type: normalizedType,
fileSize: data.size,
fileName: file.path.split("/").pop(),
})
Expand Down
12 changes: 9 additions & 3 deletions app/hooks/useAccountUpgrade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { parsePhoneNumber } from "libphonenumber-js"

// hooks
import { useActivityIndicator } from "./useActivityIndicator"
import { normalizeContentType } from "@app/utils/image-content-type"
import { useAppDispatch, useAppSelector } from "@app/store/redux"
import {
useBusinessAccountUpgradeRequestMutation,
Expand Down Expand Up @@ -112,13 +113,17 @@ export const useAccountUpgrade = () => {
return null
}

// Normalize content type: Android returns "image/jpg" for some JPEGs,
// but the backend only accepts "image/jpeg" (ENG-291)
const normalizedContentType = normalizeContentType(idDocument.type)

let data
try {
const result = await generateIdDocumentUploadUrl({
variables: {
input: {
filename: idDocument.fileName,
contentType: idDocument.type,
contentType: normalizedContentType,
},
},
})
Expand All @@ -138,7 +143,7 @@ export const useAccountUpgrade = () => {
await uploadFileToS3(
data.idDocumentUploadUrlGenerate.uploadUrl,
idDocument.uri,
idDocument.type,
normalizedContentType,
)

return data.idDocumentUploadUrlGenerate.fileKey ?? null
Expand Down Expand Up @@ -168,7 +173,8 @@ export const useAccountUpgrade = () => {
bankInfo.currency
) {
bankAccount = {
accountNumber: Number(bankInfo.accountNumber),
// accountNumber is a String in GraphQL (identifiers can exceed Int32)
accountNumber: bankInfo.accountNumber,
accountType: bankInfo.bankAccountType,
bankBranch: bankInfo.bankBranch,
bankName: bankInfo.bankName,
Expand Down
29 changes: 29 additions & 0 deletions app/utils/image-content-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Image content type utilities for ID document upload (ENG-291)
*
* Android's react-native-image-picker and Vision Camera can return "image/jpg"
* for JPEG files. The backend storage service only accepts "image/jpeg" —
* normalize before calling the upload URL mutation.
*/

/** Content types accepted by the Flash backend storage service */
export const BACKEND_ALLOWED_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp"] as const

export type AllowedContentType = (typeof BACKEND_ALLOWED_CONTENT_TYPES)[number]

/**
* Normalize platform-specific content type aliases to backend-compatible types.
* Specifically maps image/jpg → image/jpeg (Android returns image/jpg for some JPEGs).
*/
export const normalizeContentType = (contentType: string): string => {
if (contentType === "image/jpg") return "image/jpeg"
return contentType
}

/**
* Returns true if the content type (after normalization) is accepted by the backend.
* Note: always normalize before checking, or call normalizeContentType first.
*/
export const isValidContentType = (contentType: string): boolean => {
return BACKEND_ALLOWED_CONTENT_TYPES.includes(contentType as AllowedContentType)
}