mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-27 03:52:54 +02:00
Merge pull request #338 from ipnet-mesh/fix/admin-profile-edit
fix(profiles): restore admin ability to edit other users' profiles
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { ADMIN_STATE } from "../utils/helpers";
|
||||
|
||||
test.use({ storageState: ADMIN_STATE });
|
||||
|
||||
test.describe.serial("admin profile edit", () => {
|
||||
test("admin can edit another user's profile", async ({ page }) => {
|
||||
await page.goto("/members");
|
||||
|
||||
// Navigate to Mem South's profile (not the admin's own).
|
||||
await page
|
||||
.getByTestId("member-card")
|
||||
.filter({ hasText: "Mem South" })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/profile\//);
|
||||
|
||||
// Admin sees the edit button (owner does NOT — sub mismatch).
|
||||
await expect(page.getByTestId("profile-admin-edit")).toBeVisible();
|
||||
|
||||
// Click edit — form appears pre-filled with the target profile's values.
|
||||
await page.getByTestId("profile-admin-edit").click();
|
||||
await expect(page.getByTestId("profile-form")).toBeVisible();
|
||||
await expect(page.getByTestId("profile-name")).toHaveValue("Mem South");
|
||||
|
||||
// Edit callsign and save.
|
||||
await page.getByTestId("profile-callsign").fill("ADMEDIT");
|
||||
await page.getByTestId("profile-save").click();
|
||||
|
||||
// Read-only view returns with updated callsign badge.
|
||||
await expect(page.getByTestId("profile-admin-edit")).toBeVisible();
|
||||
await expect(page.getByText("ADMEDIT")).toBeVisible();
|
||||
});
|
||||
|
||||
test("admin does not see admin edit button on own profile", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to own profile via members page.
|
||||
await page.goto("/members");
|
||||
await page
|
||||
.getByTestId("member-card")
|
||||
.filter({ hasText: "PW Admin" })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/profile\//);
|
||||
|
||||
// Admin IS the owner here — should see the owner edit link, not the
|
||||
// admin edit button.
|
||||
await expect(page.getByTestId("profile-admin-edit")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,10 @@ test.describe("members", () => {
|
||||
await expect(page).toHaveURL(/\/profile\/[0-9a-f-]{36}/);
|
||||
await expect(page.getByText("Mem South").first()).toBeVisible();
|
||||
await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible();
|
||||
|
||||
// A member viewing another user's profile must NOT see the admin edit
|
||||
// button.
|
||||
await expect(page.getByTestId("profile-admin-edit")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("clicking a member node shows the node detail page", async ({ page }) => {
|
||||
|
||||
@@ -7,7 +7,12 @@ from pydantic import AnyUrl
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead, RequireUserOwner, X_USER_ID_HEADER
|
||||
from meshcore_hub.api.auth import (
|
||||
RequireRead,
|
||||
RequireUserOwner,
|
||||
X_USER_ID_HEADER,
|
||||
X_USER_ROLES_HEADER,
|
||||
)
|
||||
from meshcore_hub.api.cache import cached
|
||||
from meshcore_hub.api.cache_invalidation import (
|
||||
invalidate_dashboard,
|
||||
@@ -223,10 +228,14 @@ def update_profile(
|
||||
)
|
||||
|
||||
if profile.user_id != caller_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied: cannot modify another user's profile",
|
||||
)
|
||||
roles_header = request.headers.get(X_USER_ROLES_HEADER, "")
|
||||
roles = [r.strip() for r in roles_header.split(",") if r.strip()]
|
||||
admin_role = getattr(request.app.state, "oidc_role_admin", "admin")
|
||||
if admin_role not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied: cannot modify another user's profile",
|
||||
)
|
||||
|
||||
if profile_update.name is not None:
|
||||
profile.name = profile_update.name.strip()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Profile } from "@/pages/Profile";
|
||||
import { renderWithProviders } from "@/test/renderWithProviders";
|
||||
@@ -52,6 +52,110 @@ describe("Profile (public view)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Profile (admin edit)", () => {
|
||||
afterEach(() => {
|
||||
window.__APP_CONFIG__ = makeConfig();
|
||||
});
|
||||
|
||||
function setAdminConfig() {
|
||||
const config = makeConfig({
|
||||
oidc_enabled: true,
|
||||
user: { sub: "admin-user", name: "Admin" },
|
||||
roles: ["admin"],
|
||||
role_names: { admin: "admin", operator: "operator", member: "member" },
|
||||
});
|
||||
window.__APP_CONFIG__ = config;
|
||||
return config;
|
||||
}
|
||||
|
||||
function setMemberConfig() {
|
||||
const config = makeConfig({
|
||||
oidc_enabled: true,
|
||||
user: { sub: "other-user", name: "Member" },
|
||||
roles: ["member"],
|
||||
role_names: { admin: "admin", operator: "operator", member: "member" },
|
||||
});
|
||||
window.__APP_CONFIG__ = config;
|
||||
return config;
|
||||
}
|
||||
|
||||
it("admin sees edit button on another user's profile", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
|
||||
const config = setAdminConfig();
|
||||
renderWithProviders(<Profile />, {
|
||||
route: "/profile/p1",
|
||||
routePath: "/profile/:id",
|
||||
config,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("profile-admin-edit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("non-admin does not see edit button on another user's profile", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
|
||||
const config = setMemberConfig();
|
||||
renderWithProviders(<Profile />, {
|
||||
route: "/profile/p1",
|
||||
routePath: "/profile/:id",
|
||||
config,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Jane Operator").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(screen.queryByTestId("profile-admin-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("owner sees edit link, not admin edit button", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
|
||||
const config = makeConfig({
|
||||
oidc_enabled: true,
|
||||
user: { sub: "user-123", name: "Jane" },
|
||||
roles: ["admin"],
|
||||
role_names: { admin: "admin", operator: "operator", member: "member" },
|
||||
});
|
||||
window.__APP_CONFIG__ = config;
|
||||
renderWithProviders(<Profile />, {
|
||||
route: "/profile/p1",
|
||||
routePath: "/profile/:id",
|
||||
config,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Jane Operator").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(screen.queryByTestId("profile-admin-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("admin edit form submits to correct endpoint", async () => {
|
||||
vi.spyOn(api, "apiGet").mockResolvedValue(PROFILE_DATA);
|
||||
const apiPutSpy = vi.spyOn(api, "apiPut").mockResolvedValue(undefined);
|
||||
const config = setAdminConfig();
|
||||
renderWithProviders(<Profile />, {
|
||||
route: "/profile/p1",
|
||||
routePath: "/profile/:id",
|
||||
config,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("profile-admin-edit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("profile-admin-edit"));
|
||||
|
||||
const nameInput = await screen.findByTestId("profile-name");
|
||||
fireEvent.change(nameInput, { target: { value: "Admin Set Name" } });
|
||||
fireEvent.click(screen.getByTestId("profile-save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPutSpy).toHaveBeenCalledWith("/api/v1/user/profile/p1", {
|
||||
name: "Admin Set Name",
|
||||
callsign: "AB1CDE",
|
||||
description: "Mesh enthusiast",
|
||||
url: "https://example.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Profile (own view)", () => {
|
||||
it("shows a login prompt when OIDC is disabled", async () => {
|
||||
renderWithProviders(<Profile />, { route: "/profile" });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type FormEvent } from "react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { useAppConfig, hasRole } from "@/context/AppConfigContext";
|
||||
import { apiGet, apiPut } from "@/utils/api";
|
||||
import { qk, invalidate } from "@/utils/queryKeys";
|
||||
import { resolveNodeName, useFormatDateTime } from "@/utils/format";
|
||||
@@ -116,9 +116,120 @@ function AdoptedNodesCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileEditForm({
|
||||
profile,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
profile: UserProfileData;
|
||||
onSuccess?: () => void;
|
||||
onError?: (msg: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: string;
|
||||
body: Record<string, unknown>;
|
||||
}) => apiPut(`/api/v1/user/profile/${id}`, body),
|
||||
onSuccess: () => invalidate.profiles(queryClient),
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
const body = {
|
||||
name: String(data.get("name") ?? "").trim() || null,
|
||||
callsign: String(data.get("callsign") ?? "").trim() || null,
|
||||
description: String(data.get("description") ?? "").trim() || null,
|
||||
url: String(data.get("url") ?? "").trim() || null,
|
||||
};
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: profile.id, body });
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
onError?.((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="py-4 space-y-4" data-testid="profile-form">
|
||||
<label className="flex items-center gap-3 py-1">
|
||||
<span className="text-sm font-medium shrink-0 w-24">
|
||||
{t("user_profile.name_label")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
data-testid="profile-name"
|
||||
className="input flex-1"
|
||||
defaultValue={profile.name || ""}
|
||||
placeholder={t("user_profile.name_placeholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 py-1">
|
||||
<span className="text-sm font-medium shrink-0 w-24">
|
||||
{t("user_profile.callsign_label")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="callsign"
|
||||
data-testid="profile-callsign"
|
||||
className="input flex-1"
|
||||
defaultValue={profile.callsign || ""}
|
||||
placeholder={t("user_profile.callsign_placeholder")}
|
||||
maxLength={20}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 py-1">
|
||||
<span className="text-sm font-medium shrink-0 w-24">
|
||||
{t("user_profile.description_label")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
data-testid="profile-description"
|
||||
className="input flex-1"
|
||||
defaultValue={profile.description || ""}
|
||||
placeholder={t("user_profile.description_placeholder")}
|
||||
maxLength={500}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 py-1">
|
||||
<span className="text-sm font-medium shrink-0 w-24">
|
||||
{t("user_profile.url_label")}
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
name="url"
|
||||
data-testid="profile-url"
|
||||
className="input flex-1"
|
||||
defaultValue={profile.url || ""}
|
||||
placeholder={t("user_profile.url_placeholder")}
|
||||
maxLength={2048}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="profile-save"
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
{t("user_profile.save_profile")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicProfileView({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const config = useAppConfig();
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const { data: profile, error: queryError } = useQuery({
|
||||
queryKey: qk.profiles.detail(id),
|
||||
queryFn: ({ signal }) =>
|
||||
@@ -131,6 +242,7 @@ function PublicProfileView({ id }: { id: string }) {
|
||||
|
||||
const isOwner =
|
||||
!!config.user && !!profile.user_id && config.user.sub === profile.user_id;
|
||||
const isAdmin = hasRole("admin");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -147,34 +259,79 @@ function PublicProfileView({ id }: { id: string }) {
|
||||
{t("user_profile.edit_profile")}
|
||||
</Link>
|
||||
)}
|
||||
{!isOwner && isAdmin && !editMode && (
|
||||
<button
|
||||
data-testid="profile-admin-edit"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
setEditError(null);
|
||||
}}
|
||||
>
|
||||
{t("user_profile.edit_profile")}
|
||||
</button>
|
||||
)}
|
||||
{editMode && (
|
||||
<button
|
||||
data-testid="profile-admin-cancel"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
setEditError(null);
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<div className="card bg-base-100 shadow-xl">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">
|
||||
{profile.name || t("common.unnamed")}
|
||||
{profile.callsign && <CallsignBadge callsign={profile.callsign} />}
|
||||
</h2>
|
||||
<RoleBadges roles={profile.roles} />
|
||||
{profile.description && (
|
||||
<p className="text-sm opacity-80 mt-2">{profile.description}</p>
|
||||
)}
|
||||
{profile.url && (
|
||||
<a
|
||||
href={profile.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-primary text-sm mt-1 inline-block"
|
||||
>
|
||||
{profile.url}
|
||||
</a>
|
||||
)}
|
||||
<MemberSince createdAt={profile.created_at} />
|
||||
{hasOperatorOrAdmin(profile.roles, config) && (
|
||||
<AdoptedNodesCard profile={profile} className="mt-6" />
|
||||
)}
|
||||
{editMode && isAdmin ? (
|
||||
<div className="card bg-base-100 shadow-xl">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{t("user_profile.edit_profile")}</h2>
|
||||
<RoleBadges roles={profile.roles} />
|
||||
{editError && <ErrorAlert message={editError} />}
|
||||
<ProfileEditForm
|
||||
profile={profile}
|
||||
onSuccess={() => {
|
||||
setEditMode(false);
|
||||
setEditError(null);
|
||||
}}
|
||||
onError={(msg) => setEditError(msg)}
|
||||
/>
|
||||
<MemberSince createdAt={profile.created_at} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card bg-base-100 shadow-xl">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">
|
||||
{profile.name || t("common.unnamed")}
|
||||
{profile.callsign && (
|
||||
<CallsignBadge callsign={profile.callsign} />
|
||||
)}
|
||||
</h2>
|
||||
<RoleBadges roles={profile.roles} />
|
||||
{profile.description && (
|
||||
<p className="text-sm opacity-80 mt-2">{profile.description}</p>
|
||||
)}
|
||||
{profile.url && (
|
||||
<a
|
||||
href={profile.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link link-primary text-sm mt-1 inline-block"
|
||||
>
|
||||
{profile.url}
|
||||
</a>
|
||||
)}
|
||||
<MemberSince createdAt={profile.created_at} />
|
||||
{hasOperatorOrAdmin(profile.roles, config) && (
|
||||
<AdoptedNodesCard profile={profile} className="mt-6" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +341,6 @@ function OwnProfileView() {
|
||||
const config = useAppConfig();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: profile, error: queryError } = useQuery({
|
||||
queryKey: qk.profiles.me(),
|
||||
queryFn: ({ signal }) =>
|
||||
@@ -192,17 +348,6 @@ function OwnProfileView() {
|
||||
});
|
||||
const error = queryError ? queryError.message : null;
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: string;
|
||||
body: Record<string, unknown>;
|
||||
}) => apiPut(`/api/v1/user/profile/${id}`, body),
|
||||
onSuccess: () => invalidate.profiles(queryClient),
|
||||
});
|
||||
|
||||
if (!config.oidc_enabled || !config.user) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
@@ -221,29 +366,6 @@ function OwnProfileView() {
|
||||
const flashMessage = searchParams.get("message") || "";
|
||||
const flashError = searchParams.get("error") || "";
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
const body = {
|
||||
name: String(data.get("name") ?? "").trim() || null,
|
||||
callsign: String(data.get("callsign") ?? "").trim() || null,
|
||||
description: String(data.get("description") ?? "").trim() || null,
|
||||
url: String(data.get("url") ?? "").trim() || null,
|
||||
};
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: profile.id, body });
|
||||
navigate(
|
||||
"/profile?message=" + encodeURIComponent(t("user_profile.profile_updated")),
|
||||
{ replace: true },
|
||||
);
|
||||
} catch (err) {
|
||||
navigate(
|
||||
"/profile?error=" + encodeURIComponent((err as Error).message),
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs
|
||||
@@ -266,63 +388,22 @@ function OwnProfileView() {
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{t("user_profile.your_profile")}</h2>
|
||||
<RoleBadges roles={profile.roles} />
|
||||
<form onSubmit={handleSubmit} className="py-4 space-y-4">
|
||||
<label className="flex items-center gap-3 py-1">
|
||||
<span className="text-sm font-medium shrink-0 w-24">
|
||||
{t("user_profile.name_label")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="input flex-1"
|
||||
defaultValue={profile.name || ""}
|
||||
placeholder={t("user_profile.name_placeholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 py-1">
|
||||
<span className="text-sm font-medium shrink-0 w-24">
|
||||
{t("user_profile.callsign_label")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="callsign"
|
||||
className="input flex-1"
|
||||
defaultValue={profile.callsign || ""}
|
||||
placeholder={t("user_profile.callsign_placeholder")}
|
||||
maxLength={20}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 py-1">
|
||||
<span className="text-sm font-medium shrink-0 w-24">
|
||||
{t("user_profile.description_label")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
className="input flex-1"
|
||||
defaultValue={profile.description || ""}
|
||||
placeholder={t("user_profile.description_placeholder")}
|
||||
maxLength={500}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 py-1">
|
||||
<span className="text-sm font-medium shrink-0 w-24">
|
||||
{t("user_profile.url_label")}
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
name="url"
|
||||
className="input flex-1"
|
||||
defaultValue={profile.url || ""}
|
||||
placeholder={t("user_profile.url_placeholder")}
|
||||
maxLength={2048}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary btn-sm">
|
||||
{t("user_profile.save_profile")}
|
||||
</button>
|
||||
</form>
|
||||
<ProfileEditForm
|
||||
profile={profile}
|
||||
onSuccess={() =>
|
||||
navigate(
|
||||
"/profile?message=" +
|
||||
encodeURIComponent(t("user_profile.profile_updated")),
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
onError={(msg) =>
|
||||
navigate(
|
||||
"/profile?error=" + encodeURIComponent(msg),
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MemberSince createdAt={profile.created_at} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,10 @@ NO_ROLES_HEADERS = {
|
||||
"X-User-Id": TEST_USER_ID,
|
||||
"X-User-Roles": "",
|
||||
}
|
||||
ADMIN_HEADERS = {
|
||||
"X-User-Id": OTHER_USER_ID,
|
||||
"X-User-Roles": "admin",
|
||||
}
|
||||
|
||||
|
||||
class TestListProfiles:
|
||||
@@ -383,6 +387,27 @@ class TestUpdateProfile:
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_update_profile_admin_can_edit_other(
|
||||
self, client_no_auth, sample_user_profile
|
||||
):
|
||||
"""Test that an admin can update another user's profile."""
|
||||
response = client_no_auth.put(
|
||||
f"/api/v1/user/profile/{sample_user_profile.id}",
|
||||
json={
|
||||
"name": "Admin Edited",
|
||||
"callsign": "ADM1",
|
||||
"description": "Admin revised this",
|
||||
"url": "https://example.com/admin",
|
||||
},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Admin Edited"
|
||||
assert data["callsign"] == "ADM1"
|
||||
assert data["description"] == "Admin revised this"
|
||||
assert data["url"] == "https://example.com/admin"
|
||||
|
||||
def test_update_profile_rejects_missing_user_id(
|
||||
self, client_no_auth, sample_user_profile
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user