Skip to content
Closed
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
78 changes: 74 additions & 4 deletions components/settings/ProfileSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ProfileSection />);
expect(document.getElementById("profile")).toBeInTheDocument();
Expand All @@ -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(<ProfileSection />);
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(<ProfileSection />);
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(<ProfileSection />);
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", () => {
Expand All @@ -37,7 +107,7 @@ describe("ProfileSection", () => {
it("renders a save button", () => {
renderWithProviders(<ProfileSection />);
expect(
screen.getByRole("button", { name: "settings.save_changes" }),
screen.getByRole("button", { name: "Save Changes" }),
).toBeInTheDocument();
});
});
86 changes: 83 additions & 3 deletions components/settings/ProfileSection.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarError, setAvatarError] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const fileInputRef = useRef<HTMLInputElement>(null);

const onSave = useCallback(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
Expand All @@ -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<HTMLInputElement>) => {
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.
Expand Down Expand Up @@ -71,19 +131,39 @@ export function ProfileSection() {
<div className="divide-y divide-gray-50 dark:divide-gray-800/60">
{/* Avatar row */}
<div className="flex items-center gap-4 px-6 py-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-indigo-100 dark:bg-indigo-900/60 text-indigo-700 dark:text-indigo-300 text-lg font-semibold select-none">
AO
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-indigo-100 dark:bg-indigo-900/60 text-indigo-700 dark:text-indigo-300 text-lg font-semibold select-none overflow-hidden">
{avatarPreview ? (
<img src={avatarPreview} alt="" className="h-full w-full object-cover" />
) : (
"AO"
)}
</div>
<div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
data-testid="avatar-file-input"
onChange={handleAvatarChange}
aria-hidden="true"
tabIndex={-1}
/>
<button
className="text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 focus:outline-none focus-visible:underline transition-colors"
aria-label={t("settings.profile.change_avatar_label")}
onClick={() => fileInputRef.current?.click()}
>
{t("settings.profile.change_avatar")}
</button>
<p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{t("settings.profile.avatar_requirements")}
</p>
{avatarError && (
<p className="mt-1 text-xs text-red-500" role="alert">
{avatarError}
</p>
)}
</div>
</div>
<FieldRow
Expand Down
Loading