diff --git a/components/settings/ProfileSection.test.tsx b/components/settings/ProfileSection.test.tsx index 12918370..2c511b02 100644 --- a/components/settings/ProfileSection.test.tsx +++ b/components/settings/ProfileSection.test.tsx @@ -1,15 +1,41 @@ /** * Component test for ProfileSection. - * Translations are absent, so t() returns the key path verbatim. + * Missing translation keys fall back to the key path verbatim. */ import React from "react"; -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; import { renderWithProviders } from "@/tests/react/renderWithProviders"; import { ProfileSection } from "./ProfileSection"; +import { MAX_UPLOAD_SIZE_BYTES } from "@/lib/validation/fileSize"; + +// FileReader is not implemented in jsdom; stub the read path so the happy-path +// (valid image) test can observe the preview callback without a real reader. +class MockFileReader { + result: string | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + readAsDataURL() { + this.result = "data:image/png;base64,AAAA"; + this.onload?.(); + } +} + +function makeFile(size: number, type = "image/png", name = "avatar.png"): File { + return new File([new Uint8Array(size)], name, { type }); +} describe("ProfileSection", () => { + beforeAll(() => { + vi.stubGlobal("FileReader", MockFileReader); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + }); + it("renders inside a section with the profile id (scroll-spy target)", () => { renderWithProviders(); expect(document.getElementById("profile")).toBeInTheDocument(); @@ -24,6 +50,50 @@ describe("ProfileSection", () => { expect( screen.getByRole("button", { name: "settings.profile.change_avatar_label" }), ).toBeInTheDocument(); + expect(screen.getByTestId("avatar-file-input")).toBeInTheDocument(); + }); + + it("rejects a file over the 25 MiB cap and surfaces the error", async () => { + renderWithProviders(); + const input = screen.getByTestId("avatar-file-input") as HTMLInputElement; + + const oversized = makeFile(MAX_UPLOAD_SIZE_BYTES + 1); + fireEvent.change(input, { target: { files: [oversized] } }); + + await waitFor(() => { + expect( + screen.getByRole("alert"), + ).toHaveTextContent("settings.profile.avatar_too_large"); + }); + }); + + it("accepts a valid image file and shows a local preview", async () => { + renderWithProviders(); + const input = screen.getByTestId("avatar-file-input") as HTMLInputElement; + + const valid = makeFile(1024); + fireEvent.change(input, { target: { files: [valid] } }); + + await waitFor(() => { + // The FileReader mock should trigger the preview image + const img = document.querySelector("img"); + expect(img).toBeInTheDocument(); + }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("rejects a non-image MIME type even when under the size cap", async () => { + renderWithProviders(); + const input = screen.getByTestId("avatar-file-input") as HTMLInputElement; + + const notImage = makeFile(1024, "application/pdf", "doc.pdf"); + fireEvent.change(input, { target: { files: [notImage] } }); + + await waitFor(() => { + expect( + screen.getByRole("alert"), + ).toHaveTextContent("settings.profile.avatar_invalid_type"); + }); }); it("renders the prefilled profile fields including the disabled stellar key", () => { @@ -37,7 +107,7 @@ describe("ProfileSection", () => { it("renders a save button", () => { renderWithProviders(); expect( - screen.getByRole("button", { name: "settings.save_changes" }), + screen.getByRole("button", { name: "Save Changes" }), ).toBeInTheDocument(); }); }); diff --git a/components/settings/ProfileSection.tsx b/components/settings/ProfileSection.tsx index b1c10b13..cdbcd3a8 100644 --- a/components/settings/ProfileSection.tsx +++ b/components/settings/ProfileSection.tsx @@ -1,10 +1,15 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useRef } from "react"; import { User } from "lucide-react"; import { useClientTranslator } from "@/lib/i18n/client"; import { useAutosave } from "@/lib/hooks/useAutosave"; +import { useSafeReload } from "@/lib/hooks/useSafeReload"; import { isValidProfilePhone } from "@/lib/validation/phone"; +import { validateProfileForm } from "@/lib/validation/profile"; +import { validateFileSize, MAX_UPLOAD_SIZE_BYTES } from "@/lib/validation/fileSize"; +import { validateImageMimeType } from "@/lib/validation/imageUpload"; +import { useToast } from "@/lib/context/ToastContext"; import { SectionCard, SectionHeader, @@ -15,10 +20,15 @@ import { export function ProfileSection() { const { t } = useClientTranslator(); + const { toast } = useToast(); const [name, setName] = useState("Amara Osei"); const [email, setEmail] = useState("amara@example.com"); const [phone, setPhone] = useState("+234 801 234 5678"); const [phoneError, setPhoneError] = useState(null); + const [avatarPreview, setAvatarPreview] = useState(null); + const [avatarError, setAvatarError] = useState(null); + const [errors, setErrors] = useState>({}); + const fileInputRef = useRef(null); const onSave = useCallback(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); @@ -27,6 +37,56 @@ export function ProfileSection() { const { saveState, isDirty, triggerSave } = useAutosave(onSave); useSafeReload(isDirty); + // ─── Avatar upload ──────────────────────────────────────────────────────── + // Client-side gate: reject files over the 25 MiB cap (and non-image MIME + // types) *before* any code tries to read them into memory or upload them. + // Without this, a user or a malicious script driving the file input could + // hand a multi-gigabyte file to `FileReader.readAsDataURL`, hanging or + // crashing the tab well before any server-side limit gets a chance to run. + const handleAvatarChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + // Reset the input so selecting the same file again re-fires change. + event.target.value = ""; + setAvatarError(null); + + if (!file) return; + + const sizeResult = validateFileSize(file); + if (!sizeResult.ok) { + setAvatarError(t("settings.profile.avatar_too_large")); + toast({ + variant: "error", + title: t("settings.profile.avatar_too_large"), + description: t("settings.profile.avatar_too_large_description", { + maxMb: Math.round(MAX_UPLOAD_SIZE_BYTES / (1024 * 1024)), + }), + }); + return; + } + + const mimeResult = validateImageMimeType(file); + if (!mimeResult.ok) { + setAvatarError(t("settings.profile.avatar_invalid_type")); + toast({ + variant: "error", + title: t("settings.profile.avatar_invalid_type"), + }); + return; + } + + // Local preview so the user sees what will be uploaded before saving. + const reader = new FileReader(); + reader.onload = () => setAvatarPreview(reader.result as string); + reader.onerror = () => { + setAvatarError(t("settings.profile.avatar_read_failed")); + toast({ variant: "error", title: t("settings.profile.avatar_read_failed") }); + }; + reader.readAsDataURL(file); + }, + [t, toast], + ); + // Re-validates the full form on every field change and only triggers the // (auto)save when it passes -- an invalid field blocks the whole save // rather than persisting a partially-invalid profile. @@ -71,19 +131,39 @@ export function ProfileSection() { {/* Avatar row */} - - AO + + {avatarPreview ? ( + + ) : ( + "AO" + )} + fileInputRef.current?.click()} > {t("settings.profile.change_avatar")} {t("settings.profile.avatar_requirements")} + {avatarError && ( + + {avatarError} + + )}
{t("settings.profile.avatar_requirements")}
+ {avatarError} +