Drop the inert SaaS half of profile pictures and add real test coverage

This commit is contained in:
Anthony Stirling
2026-08-13 10:18:16 +01:00
parent a34df20be2
commit 048f7fd769
13 changed files with 256 additions and 673 deletions
@@ -174,12 +174,7 @@ window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated
$("lbl-dark").classList.toggle("on", state.theme === "dark");
}
// Resolve through D.assets when present, so a self-contained report can inline each image
// once as a data URI instead of repeating it in both the stage and the thumbnail rail.
function srcFor(v) {
var key = state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark);
return key ? ((D.assets && D.assets[key]) || key) : "";
}
function srcFor(v) { return state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark); }
function findingsForView(id) {
var all = (D.findings && D.findings.visual || []).concat(D.findings && D.findings.ux || []);
@@ -234,10 +229,8 @@ window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated
fs.forEach(function (f) {
var li = document.createElement("li"); li.className = "finding";
li.innerHTML = '<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<span>' + (f.fixed ? '<b style="color:var(--ok,#16a34a)">FIXED</b> ' : '') +
'<b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
(f.fixed && f.fixNote ? ' <span class="fix"><b>Change:</b> ' + f.fixNote + '</span>'
: f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
'<span><b>' + (f.title || "") + '</b> ' + (f.detail || "") +
(f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
notes.appendChild(li);
});
$("prev").disabled = state.i === 0;
@@ -3,11 +3,15 @@ package stirling.software.proprietary.security.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -26,11 +30,14 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.ProfilePictureService;
import stirling.software.proprietary.security.service.ProfilePictureService.InvalidProfilePictureException;
import stirling.software.proprietary.security.service.ProfilePictureService.StoredImage;
import stirling.software.proprietary.security.service.UserService;
@@ -151,4 +158,65 @@ class ProfilePictureControllerTest {
.principal(() -> "ghost"))
.andExpect(status().isUnauthorized());
}
@Test
void uploadStoresAgainstTheResolvedUserNotTheRequest() throws Exception {
mockMvc.perform(multipart("/api/v1/user/profile-picture").file(png()).principal(VIEWER))
.andExpect(status().isOk())
.andExpect(jsonPath("$.hasProfilePicture").value(true));
// The load-bearing assertion: the account written to comes from the principal, so a
// request cannot aim the upload at somebody else.
ArgumentCaptor<User> stored = ArgumentCaptor.forClass(User.class);
verify(profilePictureService).store(stored.capture(), any(MultipartFile.class));
assertThat(stored.getValue().getId()).isEqualTo(1L);
}
@Test
void aRejectedUploadIsA400CarryingTheReasonRatherThanA500() throws Exception {
doThrow(new InvalidProfilePictureException("Image is larger than 5MB"))
.when(profilePictureService)
.store(any(), any(MultipartFile.class));
mockMvc.perform(multipart("/api/v1/user/profile-picture").file(png()).principal(VIEWER))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("invalidImage"))
.andExpect(jsonPath("$.message").value("Image is larger than 5MB"));
}
@Test
void uploadFromAnUnknownPrincipalStoresNothing() throws Exception {
when(userService.findByUsernameIgnoreCase("ghost")).thenReturn(Optional.empty());
mockMvc.perform(
multipart("/api/v1/user/profile-picture")
.file(png())
.principal(() -> "ghost"))
.andExpect(status().isUnauthorized());
verifyNoInteractions(profilePictureService);
}
@Test
void removeDeletesTheSignedInUsersOwnPicture() throws Exception {
mockMvc.perform(delete("/api/v1/user/profile-picture").principal(VIEWER))
.andExpect(status().isOk())
.andExpect(jsonPath("$.hasProfilePicture").value(false));
verify(profilePictureService).delete(1L);
}
@Test
void removeFromAnUnknownPrincipalDeletesNothing() throws Exception {
when(userService.findByUsernameIgnoreCase("ghost")).thenReturn(Optional.empty());
mockMvc.perform(delete("/api/v1/user/profile-picture").principal(() -> "ghost"))
.andExpect(status().isUnauthorized());
verifyNoInteractions(profilePictureService);
}
private static MockMultipartFile png() {
return new MockMultipartFile("file", "avatar.png", "image/png", new byte[] {1, 2, 3});
}
}
@@ -475,8 +475,7 @@ public class SaasTeamController {
user.getUsername(),
user.getEmail(),
membership.getRole().name(),
membership.getAcceptedAt(),
user.getSupabaseId() == null ? null : user.getSupabaseId().toString());
membership.getAcceptedAt());
}
private TeamDetailsDTO toTeamDetailsDTO(Team team, boolean isLeader) {
@@ -521,9 +520,6 @@ public class SaasTeamController {
private final String email;
private final String role;
private final LocalDateTime joinedAt;
/** Supabase auth uuid; the avatar lives at {@code <uuid>/avatar} in profile-pictures. */
private final String supabaseAuthId;
}
@Data
@@ -1,61 +0,0 @@
-- Let teammates see each other's profile pictures.
--
-- Until now the profile-pictures bucket and its metadata table were readable only by their owner,
-- which is why the SaaS team roster fell back to initials. The policies below widen SELECT (only
-- SELECT) to "people you share a team with", matching what the self-hosted backend enforces in
-- ProfilePictureService.visibleUserIds. Writes stay owner-only: nobody can change anyone else's avatar.
--
-- Idempotent (DROP ... IF EXISTS before CREATE) so re-running is safe.
-- Team-mates of the calling user, resolved from the app's own tables. SECURITY DEFINER because the
-- caller cannot read users/team_memberships directly; the function returns only auth uuids, and is
-- restricted to authenticated callers.
CREATE OR REPLACE FUNCTION public.profile_picture_teammate_ids()
RETURNS TABLE (supabase_auth_id uuid)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT DISTINCT peer.supabase_auth_id
FROM users me
JOIN team_memberships my_membership ON my_membership.user_id = me.user_id
JOIN team_memberships peer_membership ON peer_membership.team_id = my_membership.team_id
JOIN users peer ON peer.user_id = peer_membership.user_id
WHERE me.supabase_auth_id = auth.uid()
AND peer.supabase_auth_id IS NOT NULL;
$$;
REVOKE ALL ON FUNCTION public.profile_picture_teammate_ids() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.profile_picture_teammate_ids() TO authenticated;
-- Storage: read a teammate's avatar at "<their auth uuid>/avatar". Compared as text, not cast to
-- uuid, so a stray object whose first path segment isn't a uuid can't error the whole policy.
DROP POLICY IF EXISTS "Teammates can view profile pictures" ON storage.objects;
CREATE POLICY "Teammates can view profile pictures"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'profile-pictures'
AND (storage.foldername(name))[1] IN (
SELECT supabase_auth_id::text FROM public.profile_picture_teammate_ids()
)
);
-- Metadata: the account page reads source/provider to know whether a picture is provider-synced.
-- Guarded because the metadata table is created by the Supabase-side avatar migration, which some
-- environments may not have applied yet.
DO $$
BEGIN
IF to_regclass('public.profile_picture_metadata') IS NOT NULL THEN
DROP POLICY IF EXISTS "Teammates can view profile picture metadata"
ON public.profile_picture_metadata;
CREATE POLICY "Teammates can view profile picture metadata"
ON public.profile_picture_metadata
FOR SELECT
TO authenticated
USING (user_id IN (SELECT supabase_auth_id FROM public.profile_picture_teammate_ids()));
END IF;
END
$$;
@@ -1,19 +0,0 @@
/** Synthetic avatars for the profile-picture walkthrough; base64 PNG, no external assets. */
export const THUMBS: Record<string, string> = {
ana: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAABLElEQVR4nO2Z0Q3CQAxDfVY3YAYmQ+oMjMAMWYZBGAGxAuIfJFQuvrZWeJ+ode0LbS9pO57ucIYwhzCHMIcwhzCHMIcwhzCHMIcwhzCHMIcwZxqkG+fD2y/z5THiQk27nf70/Yk2CVd2v/ywtQP8ZEuYgRKVDkOqDMxLdFuRZLB/jDJ5fnIVI12E8hXYHMIcwhzCHKJ4gDm3M5vTG7vyFUBiFSX7ak0FOqyougJKVH41JOxpqBJabkvbkbVBX2hce+L1IcwhzJkG6RrfA/G1yZLHaMIAy/vDPb4H4j8X6qP8XCisxyqhGK1FTsT+RcbuM4UT5khIVa1ASD9SICFYsgKhXv6MbMkKeAeIMf+fbvF6FdgbhDlEqQAx8g7uu0S73p5whjCHMIdbG8jyAo6jUqxC+H+BAAAAAElFTkSuQmCC",
bruno:
"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAABLElEQVR4nO2ZsRHCQBAD9RoXchkt0QYRZdAG3VAAiUN6ICGHGcb86W1rjg0ZW5b+sP13bvfDGc4Q5hDmEOYQ5hDmEOYQ5hDmEOYQ5hDmEOYQ5kyDdON6evtlPl5GXKhpt9Ofvj/RJuHK7pcftnaAn2wJM1Ci0mFIlYF5iW4rkgz2j1Emz0+uYqSLUL4Cm0OYQ5hDmEMUDzDndmZzemNXvgJIrKJkX62pQIcVVVdAicqvhoQ9DVVCy21pO7I26AuNa0+8PoQ5hDnTIF3jeyC+NlnyGE0YYHl/uMf3QPznQn2UnwuF9VglFKO1yInYv8jYfaZwwhwJqaoVCOlHCiQES1Yg1MufkS1ZAe8AMeb/0y1erwJ7gzCHKBUgRt7BfZdoz9sDzhDmEOZwawNZXg38VHoXMWs8AAAAAElFTkSuQmCC",
chen: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAABKElEQVR4nO2ZQQ6CUBBD+xs8pmyNFzJxOy68jnfxAu41MfinH2jGtzRQ2j8Cf4Z2uJzhDGEOYQ5hDmEOYQ5hDmEOYQ5hDmEOYQ5hDmHONEg3jqe3X+bbdcSFmnY7/en7E20Srux++WFrB/jJljADJSodhlQZmJfotiLJYP8YZfL85CpGugjlK7A5hDmEOYQ5RPEAc25nNqc3duUrgMQqSvbVmgp0WFF1BZSo/GpI2NNQJbTclrYja4O+0Lj2xOtDmEOYMw3SNb4H4muTJY/RhAGW94d7fA/Efy7UR/m5UFiPVUIxWouciP2LjN1nCifMkZCqWoGQfqRAQrBkBUK9/BnZkhXwDhBj/j/d4vUqsDcIc4hSAWLkHdx3iXZ/PuAMYQ5hDrc2kOUFUX1U+A3vluUAAAAASUVORK5CYII=",
dara: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAABKklEQVR4nO2ZsQ7CQAxDfVbFl+T/GFkYmdj4PKTO/AUSO0ioXHxtrfBG1Lr2hbaXtN1PBzhDmEOYQ5hDmEOYQ5hDmEOYQ5hDmEOYQ5hDmDMN0o3j5e2X+XYecaGm3U5/+v5Em4Qru19+2NoBfrIlzECJSochVQbmJbqtSDLYP0aZPD+5ipEuQvkKbA5hDmEOYQ5RPMCc25nN6Y1d+QogsYqSfbWmAh1WVF0BJSq/GhL2NFQJLbel7cjaoC80rj3x+hDmEOZMg3SN74H42mTJYzRhgOX94R7fA/GfC/VRfi4U1mOVUIzWIidi/yJj95nCCXMkpKpWIKQfKZAQLFmBUC9/RrZkBbwDxJj/T7d4vQrsDcIcolSAGHkH912iPR9XOEOYQ5jDrQ1keQG4olC7Jj75+wAAAABJRU5ErkJggg==",
eli: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAABKklEQVR4nO2Z0QnCQBBE5wZbEazHPoQtJZVYjiBiK+K/AYm3c0mG9X1KMpm5Ncntpl1OdzhDmEOYQ5hDmEOYQ5hDmEOYQ5hDmEOYQ5hDmHMYpBvX48cv0/kx4kJNu52e+56jTcKV3S8/bO0AP9kSZqBEpcOQKgPzEt1WJBnsH6NMnp9cxUgXoXwFNocwhzCHMIcoHmDK7cym9MaufAWQWEXJvlpTgQ4rqq6AEpVfDQl7GqqEltvSdmRt0Bca1554fQhzCHMOg3SN74H42mTJYzRhgOX94R7fA/GfC/VRfi4U1mOVUIzWIidi/yJj95nCCXMkpKpWIKQfKZAQLFmBUC9/RrZkBbwDxJj/T7d4vQrsDcIcolSAGHkH912iPW8vOEOYQ5jDrQ1keQMS9VT/W8XeQQAAAABJRU5ErkJggg==",
};
export const FULLS: Record<string, string> = {
ana: "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAFRElEQVR4nO3d220cRxBAUdFQBo6BkRFwDA7BMUwyDsQhEE5BMPxjEDBB7Wt2puue8y0I3b11e5YiuXp5fXv/BlW/HL0AOJIASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZD2/egFtGy///rTP/PbH38/ZS386+X17d1JHDvxX9PDrgRw0rn/PyXsQQALjP5HMngsASwz+h/J4FEEsNjofySD+wlgydH/SAb38H2Ataf/PMtYlCfAnJnzKLiBJ8CQ6T/zws5MAKOG7OTLOyEBTBuvJRZ5HgIYOFgLLfVwApg5Usst+CgCGDtMiy77yQQweYyWXvxzCGD4AA3Ywq4EQJoA5t+dYzayBwEkhmbYdh5IAKQJoHJfjtzU/QRAmgBCN+Xgrd1MAKQJoHVHjt/gtQRAmgByt2NkmxcSAGkCIE0AxTcGqc1+TQCkCYA0AZAmANIEEP2iMLjlTwmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAKL/w25wy58SAGkCIE0ApAmANAEUvyhMbfZrAiBNAKQJIPfGILLNCwmANAG0bsfxG7yWAEgTQOiOHLy1mwmANAFUbsqRm7qfAEgTQOK+HLadBxLA/KEZs5E9CIA0AQy/OwdsYVcCmDxASy/+OQQwdowWXfaTCWDmMC234KMIYOBILbTUwwlg2mAtscjzEMCo8Tr58k5IAHOG7LQLO7OX17f3o9ewqvP8V7tG/2aeAMuP3UmWsShPgIUfBUb/fgJYMgOj/ygCWCwDo/9YAlgmA6O/BwGcvQRzvysBnK4HE/9MAiDN9wFIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgLTvRy+gxY9Dn40fh96XX4g5OQHswq9ErkIA630whF8ZeyABrPrRQDJ4CAGs/QGJMriTAG7ns0EH8H2A5af/bItZiyfAqGnzjuhangBzpv/8yzshT4CZs+VRcCFPgIHTv+KCjyKAscO06LKfTACTx2jpxT+HAIYP0IAt7EoA80dnzEb2IIDE0AzbzgMJoDIuIzd1PwGEBmXw1m4mgNaIjN/gtQSQG47INi8kgOJYpDb7NQFEByK45U8JoDsKW3XjHwmAtHoA8Vtwa2+/HoCX/1v+ELoBmP7/lI+iGwB0AyjfeZ/aqgdSDCD7Yn9tSx5LMQDoBtC85y609Q4nFwB0AwjecNfaYkcUCqD20t5sKx1UKADoBpC61e63ZY6rEgB0A+jcZw+0NQ4tEQB0A4jcZHvYAkc3PwDoBlC4w3a1TT/A4QFAN4Dxt9dzbKOPcXIA8FMCIG1sALMf3E+2zT3MsQFAN4DBN9ZRtqFHOjMAuJAASBsYwNSH9eG2iQc7MADoBjDyljqPbdzxTgsAriIA0kYFMO8BfULbrEMeFQBcSwCkCYC0OQEMe296Ztugo54TANxAAKQNCWDSQ3kJ25QDHxIA3EYApAmAtAkBjHk/upZtxLFPCABuJgDSBECaAEhbPoAZX4otalv/8JcPAO4hANIEQJoASFs7gAFfhK1uW/wlWDsAuJMASBMAaQIgTQCkLRzA6v/+MMa28guxcABwv5c///rxgL8G1uQJQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AfCt7B9ECS6hVYEPQwAAAABJRU5ErkJggg==",
bruno:
"iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAFRUlEQVR4nO3dy20cRxRAUdFQIL1jSk7DK4bhNJyNA+DGS+egjeGNQcAENb+e7nr3nLUgVNW8Wz0UydHL++vbN6j65egFwJEEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYC070cvoGX747ef/pm/fv39KWvhXy/vr29O4tiJ/5oediWAk879/ylhDwJYYPQ/ksFjCWCZ0f9IBo8igMVG/yMZ3E8AS47+RzK4h+8DrD3951nGojwB5sycR8ENPAGGTP+ZF3ZmAhg1ZCdf3gkJYNp4LbHI8xDAwMFaaKmHE8DMkVpuwUcRwNhhWnTZTyaAyWO09OKfQwDDB2jAFnYlANIEMP/uHLORPQggMTTDtvNAAiBNAJX7cuSm7icA0gQQuikHb+1mAiBNAK07cvwGryUA0gSQux0j27yQAEgTAGkCKL4xSG32awIgTQCkCYA0AZAmgOgXhcEtf0oApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBBD9H3aDW/6UAEgTAGkCIE0ApAmg+EVharNfEwBpAiBNALk3BpFtXkgApAmgdTuO3+C1BECaAEJ35OCt3UwApAmgclOO3NT9BECaABL35bDtPJAA5g/NmI3sQQCkCWD43TlgC7sSwOQBWnrxzyGAsWO06LKfTAAzh2m5BR9FAANHaqGlHk4A0wZriUWehwBGjdfJl3dCApgzZKdd2Jm9vL++Hb2GVZ3nv9o1+jfzBFh+7E6yjEV5Aiz8KDD69xPAkhkY/UcRwGIZGP3HEsAyGRj9PQjg7CWY+10J4HQ9mPhnEgBpvg9AmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKR9P3oBLX4c+mz8OPS+/ELMyQlgF34lchUCWO+DIfzK2AMJYNWPBpLBQwhg7Q9IlMGdBHA7nw06gO8DLD/9Z1vMWjwBRk2bd0TX8gSYM/3nX94JeQLMnC2Pggt5Agyc/hUXfBQBjB2mRZf9ZAKYPEZLL/45BDB8gAZsYVcCmD86YzayBwEkhmbYdh5IAJVxGbmp+wkgNCiDt3YzAbRGZPwGryWA3HBEtnkhARTHIrXZrwkgOhDBLX9KAN1R2Kob/0gApNUDiN+CW3v79QC8/N/yh9ANwPT/p3wU3QCgG0D5zvvUVj2QYgDZF/trW/JYigFAN4DmPXehrXc4uQCgG0DwhrvWFjuiUAC1l/ZmW+mgQgFAN4DUrXa/LXNclQCgG0DnPnugrXFoiQCgG0DkJtvDFji6+QFAN4DCHbarbfoBDg8AugGMv72eYxt9jJMDgJ8SAGljA5j94H6ybe5hjg0AugEMvrGOsg090pkBwIUEQNrAAKY+rA+3TTzYgQFAN4CRt9R5bOOOd1oAcBUBkDYqgHkP6BPaZh3yqADgWgIgTQCkzQlg2HvTM9sGHfWcAOAGAiBtSACTHspL2KYc+JAA4DYCIE0ApE0IYMz70bVsI459QgBwMwGQJgDSBEDa8gHM+FJsUdv6h798AHAPAZAmANIEQNraAQz4Imx12+IvwdoBwJ0EQJoASBMAaQIgbeEAVv/3hzG2lV+IhQOA+738+PPvB/w1sCZPANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYBvZf8AXQg1Y7gVrqcAAAAASUVORK5CYII=",
chen: "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAFQElEQVR4nO3dO24cRxRAUdEQl2mnBjdEwGk78Ha8F23AcGIQMEHNr6e73j0nFoSqmnerhyI5enl9f/sGVb8cvQA4kgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkPb96AW0bL/+/tM/89uffzxlLfzr5fX9zUkcO/Ff08OuBHDSuf8/JexBAAuM/kcyeCwBLDP6H8ngUQSw2Oh/JIP7CWDJ0f9IBvfwfYC1p/88y1iUJ8CcmfMouIEnwJDpP/PCzkwAo4bs5Ms7IQFMG68lFnkeAhg4WAst9XACmDlSyy34KAIYO0yLLvvJBDB5jJZe/HMIYPgADdjCrgRAmgDm351jNrIHASSGZth2HkgApAmgcl+O3NT9BECaAEI35eCt3UwApAmgdUeO3+C1BECaAHK3Y2SbFxIAaQIgTQDFNwapzX5NAKQJgDQBkCYA0gQQ/aIwuOVPCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoAov/DbnDLnxIAaQIgTQCkCYA0ARS/KExt9msCIE0ApAkg98Ygss0LCYA0AbRux/EbvJYASBNA6I4cvLWbCYA0AVRuypGbup8ASBNA4r4ctp0HEsD8oRmzkT0IgDQBDL87B2xhVwKYPEBLL/45BDB2jBZd9pMJYOYwLbfgowhg4EgttNTDCWDaYC2xyPMQwKjxOvnyTkgAc4bstAs7s5fX97ej17Cq8/xXu0b/Zp4Ay4/dSZaxKE+AhR8FRv9+AlgyA6P/KAJYLAOj/1gCWCYDo78HAZy9BHO/KwGcrgcT/0wCIM33AUgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmAtO9HL6DFj0OfjR+H3pdfiDk5AezCr0SuQgDrfTCEXxl7IAGs+tFAMngIAaz9AYkyuJMAbuezQQfwfYDlp/9si1mLJ8CoafOO6FqeAHOm//zLOyFPgJmz5VFwIU+AgdO/4oKPIoCxw7Tosp9MAJPHaOnFP4cAhg/QgC3sSgDzR2fMRvYggMTQDNvOAwmgMi4jN3U/AYQGZfDWbiaA1oiM3+C1BJAbjsg2LySA4likNvs1AUQHIrjlTwmgOwpbdeMfCYC0egDxW3Brb78egJf/W/4QugGY/v+Uj6IbAHQDKN95n9qqB1IMIPtif21LHksxAOgG0LznLrT1DicXAHQDCN5w19piRxQKoPbS3mwrHVQoAOgGkLrV7rdljqsSAHQD6NxnD7Q1Di0RAHQDiNxke9gCRzc/AOgGULjDdrVNP8DhAUA3gPG313Nso49xcgDwUwIgbWwAsx/cT7bNPcyxAUA3gME31lG2oUc6MwC4kABIGxjA1If14baJBzswAOgGMPKWOo9t3PFOCwCuIgDSRgUw7wF9QtusQx4VAFxLAKQJgLQ5AQx7b3pm26CjnhMA3EAApA0JYNJDeQnblAMfEgDcRgCkCYC0CQGMeT+6lm3EsU8IAG4mANIEQJoASFs+gBlfii1qW//wlw8A7iEA0gRAmgBIWzuAAV+ErW5b/CVYOwC4kwBIEwBpAiBNAKQtHMDq//4wxrbyC7FwAHC/l79+/P2AvwbW5AlAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQB8K3sH4FUN9lV36c2AAAAAElFTkSuQmCC",
dara: "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAFQklEQVR4nO3dO24cRxRAUdEQuJLen0MmDh058/IIOPYuBBhODAImqPn1dNe758SCUFXzbvVQJEcv72+v36Dql6MXAEcSAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSvh+9gJbt199/+mf++vO3p6yFf728v706iWMn/mt62JUATjr3/6eEPQhggdH/SAaPJYBlRv8jGTyKABYb/Y9kcD8BLDn6H8ngHr4PsPb0n2cZi/IEmDNzHgU38AQYMv1nXtiZCWDUkJ18eSckgGnjtcQiz0MAAwdroaUeTgAzR2q5BR9FAGOHadFlP5kAJo/R0ot/DgEMH6ABW9iVAEgTwPy7c8xG9iCAxNAM284DCYA0AVTuy5Gbup8ASBNA6KYcvLWbCYA0AbTuyPEbvJYASBNA7naMbPNCAiBNAKQJoPjGILXZrwmANAGQJgDSBECaAKJfFAa3/CkBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIE0D0f9gNbvlTAiBNAKQJgDQBkCaA4heFqc1+TQCkCYA0AeTeGES2eSEBkCaA1u04foPXEgBpAgjdkYO3djMBkCaAyk05clP3EwBpAkjcl8O280ACmD80YzayBwGQJoDhd+eALexKAJMHaOnFP4cAxo7Rost+MgHMHKblFnwUAQwcqYWWejgBTBusJRZ5HgIYNV4nX94JCWDOkJ12YWf28v72evQaVnWe/2rX6N/ME2D5sTvJMhblCbDwo8Do308AS2Zg9B9FAItlYPQfSwDLZGD09yCAs5dg7nclgNP1YOKfSQCk+T4AaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZD2/egFtPhx6LPx49D78gsxJyeAXfiVyFUIYL0PhvArYw8kgFU/GkgGDyGAtT8gUQZ3EsDtfDboAL4PsPz0n20xa/EEGDVt3hFdyxNgzvSff3kn5Akwc7Y8Ci7kCTBw+ldc8FEEMHaYFl32kwlg8hgtvfjnEMDwARqwhV0JYP7ojNnIHgSQGJph23kgAVTGZeSm7ieA0KAM3trNBNAakfEbvJYAcsMR2eaFBFAci9RmvyaA6EAEt/wpAXRHYatu/CMBkFYPIH4Lbu3t1wPw8n/LH0I3ANP/n/JRdAOAbgDlO+9TW/VAigFkX+yvbcljKQYA3QCa99yFtt7h5AKAbgDBG+5aW+yIQgHUXtqbbaWDCgUA3QBSt9r9tsxxVQKAbgCd++yBtsahJQKAbgCRm2wPW+Do5gcA3QAKd9iutukHODwA6AYw/vZ6jm30MU4OAH5KAKSNDWD2g/vJtrmHOTYA6AYw+MY6yjb0SGcGABcSAGkDA5j6sD7cNvFgBwYA3QBG3lLnsY073mkBwFUEQNqoAOY9oE9om3XIowKAawmANAGQNieAYe9Nz2wbdNRzAoAbCIC0IQFMeigvYZty4EMCgNsIgDQBkDYhgDHvR9eyjTj2CQHAzQRAmgBIEwBpywcw40uxRW3rH/7yAcA9BECaAEgTAGlrBzDgi7DVbYu/BGsHAHcSAGkCIE0ApAmAtIUDWP3fH8bYVn4hFg4A7vfy4+8/HvDXwJo8AUgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgC+lf0DFy4neu4J5X4AAAAASUVORK5CYII=",
eli: "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAFQklEQVR4nO3dXWocRxSAUSt4KwGtJ/sQ9FKykiwnIEK2YkJegiBCnr+e7rrfOc/GVNXcr3pkSeOXt9f3b1D1y9ELgCMJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBp349eQMv2x68//TO///bXU9bCv17eXt+dxLET/zU97EoAJ537/1PCHgSwwOh/JIPHEsAyo/+RDB5FAIuN/kcyuJ8Alhz9j2RwD98HWHv6z7OMRXkCzJk5j4IbeAIMmf4zL+zMBDBqyE6+vBMSwLTxWmKR5yGAgYO10FIPJ4CZI7Xcgo8igLHDtOiyn0wAk8do6cU/hwCGD9CALexKAKQJYP7dOWYjexBAYmiGbeeBBECaACr35chN3U8ApAkgdFMO3trNBECaAFp35PgNXksApAkgdztGtnkhAZAmANIEUHxjkNrs1wRAmgBIEwBpAiBNANEvCoNb/pQASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCSD6P+wGt/wpAZAmANIEQJoASBNA8YvC1Ga/JgDSBECaAHJvDCLbvJAASBNA63Ycv8FrCYA0AYTuyMFbu5kASBNA5aYcuan7CYA0ASTuy2HbeSABzB+aMRvZgwBIE8Dwu3PAFnYlgMkDtPTin0MAY8do0WU/mQBmDtNyCz6KAAaO1EJLPZwApg3WEos8DwGMGq+TL++EBDBnyE67sDN7eXt9P3oNqzrPf7Vr9G/mCbD82J1kGYvyBFj4UWD07yeAJTMw+o8igMUyMPqPJYBlMjD6exDA2Usw97sSwOl6MPHPJADSfB+ANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEj7fvQCWvw49Nn4ceh9+YWYkxPALvxK5CoEsN4HQ/iVsQcSwKofDSSDhxDA2h+QKIM7CeB2Pht0AN8HWH76z7aYtXgCjJo274iu5QkwZ/rPv7wT8gSYOVseBRfyBBg4/Ssu+CgCGDtMiy77yQQweYyWXvxzCGD4AA3Ywq4EMH90xmxkDwJIDM2w7TyQACrjMnJT9xNAaFAGb+1mAmiNyPgNXksAueGIbPNCAiiORWqzXxNAdCCCW/6UALqjsFU3/pEASKsHEL8Ft/b26wF4+b/lD6EbgOn/T/kougFAN4DynfeprXogxQCyL/bXtuSxFAOAbgDNe+5CW+9wcgFAN4DgDXetLXZEoQBqL+3NttJBhQKAbgCpW+1+W+a4KgFAN4DOffZAW+PQEgFAN4DITbaHLXB08wOAbgCFO2xX2/QDHB4AdAMYf3s9xzb6GCcHAD8lANLGBjD7wf1k29zDHBsAdAMYfGMdZRt6pDMDgAsJgLSBAUx9WB9um3iwAwOAbgAjb6nz2MYd77QA4CoCIG1UAPMe0Ce0zTrkUQHAtQRAmgBImxPAsPemZ7YNOuo5AcANBEDakAAmPZSXsE058CEBwG0EQJoASJsQwJj3o2vZRhz7hADgZgIgTQCkCYC05QOY8aXYorb1D3/5AOAeAiBNAKQJgLS1AxjwRdjqtsVfgrUDgDsJgDQBkCYA0gRA2sIBrP7vD2NsK78QCwcA93v5+88fD/hrYE2eAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwDfyv4BlqY3Zt5uyXwAAAAASUVORK5CYII=",
};
@@ -1,502 +0,0 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
import path from "node:path";
import { openSettings } from "@app/tests/helpers/ui-helpers";
import { THUMBS, FULLS } from "@app/tests/stubbed/avatar-fixtures";
/**
* Screenshot walkthrough of per-user profile pictures; dumps PNGs to
* screenshots/profile-pictures. Throwaway harness for the UI report, not a assertion suite.
*/
const SCREENSHOTS_DIR = path.resolve(
process.cwd(),
"screenshots",
"profile-pictures",
);
function shotPath(name: string): string {
return path.join(SCREENSHOTS_DIR, `${name}.png`);
}
async function settle(page: Page, ms = 450): Promise<void> {
await page.waitForTimeout(ms);
}
/** Ids 1-6 mirror a small org: three of six people have uploaded a picture. */
const ROSTER = [
{ id: 1, username: "ana.oyelaran", team: "Design", avatar: "ana" },
{ id: 2, username: "bruno.katz", team: "Design", avatar: "bruno" },
{ id: 3, username: "chen.wei", team: "Design", avatar: null },
{ id: 4, username: "dara.singh", team: "Engineering", avatar: "dara" },
{ id: 5, username: "eli.novak", team: "Engineering", avatar: null },
{ id: 6, username: "fatima.rahman", team: "Engineering", avatar: null },
];
const TEAMS = [
{ id: 1, name: "Design" },
{ id: 2, name: "Engineering" },
];
function adminSettingsPayload() {
return {
users: ROSTER.map((u) => ({
id: u.id,
username: u.username,
email: `${u.username}@example.com`,
roleName: "adminUserSettings.user",
rolesAsString: u.id === 1 ? "ROLE_ADMIN" : "ROLE_USER",
enabled: u.id !== 6,
authenticationType: "web",
team: { id: u.team === "Design" ? 1 : 2, name: u.team },
hasProfilePicture: u.avatar !== null,
portalAccess: u.id === 1,
teamLead: u.id === 4,
})),
userSessions: { "ana.oyelaran": true, "dara.singh": true },
userLastRequest: {
"ana.oyelaran": Date.now() - 60_000,
"bruno.katz": Date.now() - 3_600_000,
"chen.wei": Date.now() - 86_400_000,
"dara.singh": Date.now() - 120_000,
},
userSettings: {},
lockedUsers: [],
totalUsers: ROSTER.length,
activeUsers: 2,
disabledUsers: 1,
currentUsername: "ana.oyelaran",
maxAllowedUsers: 100,
availableSlots: 94,
grandfatheredUserCount: 0,
licenseMaxUsers: 100,
premiumEnabled: true,
mailEnabled: true,
emailInvitesEnabled: true,
teams: TEAMS,
roleDetails: { ROLE_ADMIN: "Admin", ROLE_USER: "User" },
};
}
/** Everything the avatar surfaces need. `ownAvatar` drives the signed-in user's own picture. */
async function stubAvatarApis(
page: Page,
opts: { ownAvatar?: string | null } = {},
): Promise<void> {
const { ownAvatar = "ana" } = opts;
await page.route("**/api/v1/user/profile-picture", async (route: Route) => {
if (route.request().method() !== "GET") {
return route.fulfill({ json: { hasProfilePicture: true } });
}
if (!ownAvatar) return route.fulfill({ status: 404, body: "" });
return route.fulfill({
status: 200,
contentType: "image/png",
body: Buffer.from(FULLS[ownAvatar], "base64"),
});
});
await page.route("**/api/v1/user/profile-pictures*", (route: Route) => {
const requested =
new URL(route.request().url()).searchParams
.get("userIds")
?.split(",")
.filter(Boolean) ?? [];
const body: Record<string, string> = {};
for (const id of requested) {
const person = ROSTER.find((u) => String(u.id) === id);
if (person?.avatar) {
body[id] = `data:image/png;base64,${THUMBS[person.avatar]}`;
}
}
return route.fulfill({ json: body });
});
await page.route("**/ui-data/admin-settings", (route: Route) =>
route.fulfill({ json: adminSettingsPayload() }),
);
await page.route("**/ui-data/account", (route: Route) =>
route.fulfill({
json: {
username: "ana.oyelaran",
role: "ROLE_ADMIN",
settings: "{}",
changeCredsFlag: false,
oAuth2Login: false,
saml2Login: false,
mfaEnabled: false,
mfaRequired: false,
},
}),
);
await page.route("**/ui-data/teams", (route: Route) =>
route.fulfill({
json: {
teamsWithCounts: TEAMS.map((t) => ({
id: t.id,
name: t.name,
userCount: ROSTER.filter((u) => u.team === t.name).length,
})),
teamLastRequest: {},
teamOwners: { 2: ["dara.singh"] },
},
}),
);
await page.route("**/ui-data/teams/*", (route: Route) => {
const teamId = Number(route.request().url().split("/").pop());
const team = TEAMS.find((t) => t.id === teamId) ?? TEAMS[0];
const members = ROSTER.filter(
(u) => (u.team === "Design" ? 1 : 2) === teamId,
);
return route.fulfill({
json: {
team: { id: team.id, name: team.name },
teamUsers: members.map((u) => ({
id: u.id,
username: u.username,
email: `${u.username}@example.com`,
enabled: true,
roleName: "adminUserSettings.user",
rolesAsString: "ROLE_USER",
team: { id: team.id, name: team.name },
})),
availableUsers: [],
userLastRequest: {},
ownerUserIds: [4],
},
});
});
await page.route("**/api/v1/team/**", (route: Route) =>
route.fulfill({ json: [] }),
);
}
/** The portal only mounts for a session carrying portalAccess (RequirePortalAccess). */
async function stubPortalSession(page: Page): Promise<void> {
await page.route("**/api/v1/auth/me", (route: Route) =>
route.fulfill({
json: {
user: {
id: 1,
username: "ana.oyelaran",
email: "ana.oyelaran@example.com",
roles: ["ROLE_ADMIN"],
role: "ROLE_ADMIN",
portalAccess: true,
team: { id: 1, name: "Design" },
enabled: true,
},
portalAccess: true,
},
}),
);
await page.route("**/api/v1/policies**", (route: Route) =>
route.fulfill({ json: [] }),
);
await page.route("**/api/v1/access/**", (route: Route) =>
route.fulfill({ json: [] }),
);
}
async function openAccountSection(page: Page): Promise<void> {
await openSettings(page);
await page.getByText("Account Settings", { exact: true }).first().click();
await expect(page.getByText("Profile picture").first()).toBeVisible({
timeout: 10_000,
});
await settle(page);
}
async function openWorkspaceSection(page: Page, label: string): Promise<void> {
await openSettings(page);
await page.getByText(label, { exact: true }).first().click();
await settle(page, 1200);
}
/** Hand the profile-picture FilePicker a synthetic file, bypassing the OS dialog. */
async function pickAvatarFile(page: Page, sizeBytes: number): Promise<void> {
const input = page.locator('input[type="file"][accept*="webp"]').last();
await input.setInputFiles({
name: "avatar.png",
mimeType: "image/png",
buffer:
sizeBytes <= 0
? Buffer.from(FULLS.bruno, "base64")
: Buffer.alloc(sizeBytes, 1),
});
}
async function enableDarkMode(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("mantine-color-scheme", "dark");
localStorage.setItem("mantine-color-scheme-value", "dark");
});
await page.emulateMedia({ colorScheme: "dark" });
}
async function enableRtl(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("i18nextLng", "ar-AR");
localStorage.setItem("stirling-language", "ar-AR");
localStorage.setItem("stirling-language-source", "user");
const applyDir = () => {
document.documentElement.setAttribute("dir", "rtl");
document.documentElement.setAttribute("lang", "ar-AR");
};
if (document.documentElement) applyDir();
else document.addEventListener("DOMContentLoaded", applyDir);
});
}
const LOGGED_IN = {
autoGoto: false as const,
viewport: { width: 1600, height: 1000 },
seedJwt: true,
stubOptions: {
enableLogin: true,
isAdmin: true,
user: {
id: 1,
username: "ana.oyelaran",
email: "ana.oyelaran@example.com",
roles: ["ROLE_ADMIN"],
},
},
};
/* ── Light pass ─────────────────────────────────────────────────────────── */
test.describe("Profile pictures walkthrough", () => {
test.use(LOGGED_IN);
test("01_account_card_empty", async ({ page }) => {
await stubAvatarApis(page, { ownAvatar: null });
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAccountSection(page);
await page.screenshot({ path: shotPath("01_account_card_empty_light") });
});
test("02_account_card_with_picture", async ({ page }) => {
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAccountSection(page);
await page.screenshot({
path: shotPath("02_account_card_with_picture_light"),
});
});
test("03_cropper_modal", async ({ page }) => {
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAccountSection(page);
await pickAvatarFile(page, 0);
await expect(page.getByText("Crop Profile Picture")).toBeVisible({
timeout: 10_000,
});
await settle(page, 900);
await page.screenshot({ path: shotPath("03_cropper_modal_light") });
});
test("04_size_error", async ({ page }) => {
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAccountSection(page);
await pickAvatarFile(page, 6 * 1024 * 1024);
await expect(page.getByText(/smaller than 5MB/i)).toBeVisible({
timeout: 10_000,
});
await settle(page);
await page.screenshot({ path: shotPath("04_size_error_light") });
});
test("05_sidebar_avatar", async ({ page }) => {
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.locator(".file-sidebar-bottom-avatar--picture"),
).toBeVisible({ timeout: 10_000 });
await settle(page);
await page.screenshot({ path: shotPath("05_sidebar_avatar_light") });
});
test("06_sidebar_initials", async ({ page }) => {
await stubAvatarApis(page, { ownAvatar: null });
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator(".file-sidebar-bottom-avatar")).toBeVisible({
timeout: 10_000,
});
await settle(page);
await page.screenshot({ path: shotPath("06_sidebar_initials_light") });
});
test("07_people_roster", async ({ page }) => {
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openWorkspaceSection(page, "People");
await page.screenshot({ path: shotPath("07_people_roster_light") });
});
test("08_team_details", async ({ page }) => {
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openWorkspaceSection(page, "Teams");
const row = page.getByText("Design", { exact: true }).first();
if (await row.isVisible().catch(() => false)) {
await row.click();
await settle(page, 1200);
}
await page.screenshot({ path: shotPath("08_team_details_light") });
});
test("09_portal_users", async ({ page }) => {
await stubAvatarApis(page);
await stubPortalSession(page);
await page.goto("/processor/users", { waitUntil: "domcontentloaded" });
await settle(page, 2500);
await page.screenshot({ path: shotPath("09_portal_users_light") });
});
/* ── Dark pass ────────────────────────────────────────────────────────── */
test("01_account_card_empty_dark", async ({ page }) => {
await enableDarkMode(page);
await stubAvatarApis(page, { ownAvatar: null });
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAccountSection(page);
await page.screenshot({ path: shotPath("01_account_card_empty_dark") });
});
test("02_account_card_with_picture_dark", async ({ page }) => {
await enableDarkMode(page);
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAccountSection(page);
await page.screenshot({
path: shotPath("02_account_card_with_picture_dark"),
});
});
test("03_cropper_modal_dark", async ({ page }) => {
await enableDarkMode(page);
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAccountSection(page);
await pickAvatarFile(page, 0);
await expect(page.getByText("Crop Profile Picture")).toBeVisible({
timeout: 10_000,
});
await settle(page, 900);
await page.screenshot({ path: shotPath("03_cropper_modal_dark") });
});
test("04_size_error_dark", async ({ page }) => {
await enableDarkMode(page);
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAccountSection(page);
await pickAvatarFile(page, 6 * 1024 * 1024);
await expect(page.getByText(/smaller than 5MB/i)).toBeVisible({
timeout: 10_000,
});
await settle(page);
await page.screenshot({ path: shotPath("04_size_error_dark") });
});
test("05_sidebar_avatar_dark", async ({ page }) => {
await enableDarkMode(page);
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.locator(".file-sidebar-bottom-avatar--picture"),
).toBeVisible({ timeout: 10_000 });
await settle(page);
await page.screenshot({ path: shotPath("05_sidebar_avatar_dark") });
});
test("06_sidebar_initials_dark", async ({ page }) => {
await enableDarkMode(page);
await stubAvatarApis(page, { ownAvatar: null });
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator(".file-sidebar-bottom-avatar")).toBeVisible({
timeout: 10_000,
});
await settle(page);
await page.screenshot({ path: shotPath("06_sidebar_initials_dark") });
});
test("07_people_roster_dark", async ({ page }) => {
await enableDarkMode(page);
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openWorkspaceSection(page, "People");
await page.screenshot({ path: shotPath("07_people_roster_dark") });
});
test("08_team_details_dark", async ({ page }) => {
await enableDarkMode(page);
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openWorkspaceSection(page, "Teams");
const row = page.getByText("Design", { exact: true }).first();
if (await row.isVisible().catch(() => false)) {
await row.click();
await settle(page, 1200);
}
await page.screenshot({ path: shotPath("08_team_details_dark") });
});
test("09_portal_users_dark", async ({ page }) => {
await enableDarkMode(page);
await stubAvatarApis(page);
await stubPortalSession(page);
await page.goto("/processor/users", { waitUntil: "domcontentloaded" });
await settle(page, 2500);
await page.screenshot({ path: shotPath("09_portal_users_dark") });
});
test("12_remove_confirm", async ({ page }) => {
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAccountSection(page);
await page.getByRole("button", { name: "Remove" }).first().click();
await expect(page.getByText("Remove profile picture")).toBeVisible({
timeout: 10_000,
});
await settle(page, 700);
await page.screenshot({ path: shotPath("12_remove_confirm_light") });
});
test("12_remove_confirm_dark", async ({ page }) => {
await enableDarkMode(page);
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openAccountSection(page);
await page.getByRole("button", { name: "Remove" }).first().click();
await expect(page.getByText("Remove profile picture")).toBeVisible({
timeout: 10_000,
});
await settle(page, 700);
await page.screenshot({ path: shotPath("12_remove_confirm_dark") });
});
/* ── RTL pass (layout-sensitive views only) ───────────────────────────── */
test("10_account_card_rtl", async ({ page }) => {
await enableRtl(page);
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page);
await settle(page, 1200);
await page.screenshot({ path: shotPath("10_account_card_rtl_light") });
});
test("11_people_roster_rtl", async ({ page }) => {
await enableRtl(page);
await stubAvatarApis(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page);
await settle(page, 1500);
await page.screenshot({ path: shotPath("11_people_roster_rtl_light") });
});
});
@@ -1,11 +0,0 @@
/**
* Per-flavor source of roster avatar URLs, resolved via the `@app/*` alias like `usersBackend`.
* Self-hosted keys by user id (data URLs); SaaS keys by Supabase auth uuid (signed storage URLs).
*/
export interface MemberAvatarSource {
/**
* URLs for the given avatar keys, keyed the same way. Keys with no picture, or that the caller
* may not see, are absent so the row falls back to initials. Never rejects: avatars decorate.
*/
resolve(keys: string[]): Promise<Record<string, string>>;
}
+4 -3
View File
@@ -37,7 +37,7 @@ export interface Member {
lastActive: string;
/**
* Optional avatar image; falls back to initials when absent. Self-hosted supplies a data URL (the
* bearer-token transport rules out a plain image URL); SaaS supplies a signed storage URL.
* bearer-token transport rules out a plain image URL); SaaS has none yet.
*/
avatarUrl?: string;
/** Backend linkage for row actions (absent on pure fixtures). */
@@ -285,9 +285,10 @@ const AVATAR_BATCH_SIZE = 200;
/**
* Roster avatars as data URLs, keyed by user id. Data URLs because the portal authenticates with a
* bearer token, which an `<img src>` request would not carry. Avatars are decoration, so a failure
* degrades to initials rather than failing the roster.
* degrades to initials rather than failing the roster. Kept on the portal's own transport rather
* than reusing the editor's service, which would pull the editor apiClient into this bundle.
*/
export async function fetchAvatarThumbnails(
async function fetchAvatarThumbnails(
userIds: string[],
): Promise<Record<string, string>> {
const ids = Array.from(new Set(userIds)).filter(Boolean);
@@ -119,7 +119,7 @@ describe("UsersDirectory - member avatars", () => {
document.querySelector<HTMLImageElement>("img.sui-avatar__img");
it("shows the member's picture when the roster carried one", () => {
// Self-hosted supplies a data URL and SaaS a signed URL; the row only cares that it has one.
// Self-hosted supplies a data URL; the row only cares that it has one.
const withPicture: Member = {
...MEMBER,
avatarUrl: "data:image/png;base64,AQID",
@@ -0,0 +1,179 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
const h = vi.hoisted(() => ({
maxBytes: 5 * 1024 * 1024,
upload: vi.fn(),
remove: vi.fn(),
refresh: vi.fn(),
pictureUrl: null as string | null,
/** Set by the cropper stub so a test can drive the post-crop upload. */
cropComplete: null as ((blob: Blob) => void) | null,
}));
vi.mock("@app/services/profilePictureService", () => ({
MAX_PROFILE_PICTURE_BYTES: h.maxBytes,
PROFILE_PICTURE_ACCEPT: "image/png,image/jpeg,image/webp",
uploadProfilePicture: h.upload,
removeProfilePicture: h.remove,
}));
vi.mock("@app/hooks/useProfilePictureUrl", () => ({
useProfilePictureUrl: () => h.pictureUrl,
refreshOwnProfilePicture: h.refresh,
}));
// The real cropper pulls in react-easy-crop and a canvas; the card only cares that it opened.
vi.mock("@app/components/shared/config/ProfilePictureCropper", () => ({
ProfilePictureCropper: ({
opened,
onCropComplete,
}: {
opened: boolean;
onCropComplete: (blob: Blob) => void;
}) => {
h.cropComplete = onCropComplete;
return opened ? <div data-testid="cropper" /> : null;
},
}));
vi.mock("@app/components/shared/LocalIcon", () => ({
default: () => <span />,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string, vars?: Record<string, unknown>) =>
(fallback ?? _key).replace(/\{\{(\w+)\}\}/g, (_match, name: string) =>
String(vars?.[name] ?? ""),
),
i18n: { changeLanguage: vi.fn() },
}),
}));
import ProfilePictureCard from "@app/components/shared/config/ProfilePictureCard";
function renderCard() {
return render(
<MantineProvider>
<ProfilePictureCard displayName="Priya Raman" />
</MantineProvider>,
);
}
/** Hands the hidden file input a file of the given nominal size, bypassing the OS dialog. */
function pickFile(sizeBytes: number) {
const input = document.querySelector<HTMLInputElement>(
'input[type="file"]',
) as HTMLInputElement;
const file = new File(["x"], "avatar.png", { type: "image/png" });
Object.defineProperty(file, "size", { value: sizeBytes });
Object.defineProperty(input, "files", { value: [file], configurable: true });
fireEvent.change(input);
}
/** Drives the cropper stub's callback, i.e. everything after the user confirms the crop. */
async function finishCrop() {
await act(async () => {
await h.cropComplete?.(new Blob(["x"]));
});
}
beforeEach(() => {
h.upload.mockReset();
h.remove.mockReset();
h.refresh.mockReset().mockResolvedValue(undefined);
h.pictureUrl = null;
h.cropComplete = null;
});
describe("ProfilePictureCard - size gate", () => {
it("refuses a file over the limit and names the limit in the error", () => {
renderCard();
pickFile(h.maxBytes + 1);
expect(
screen.getByText("Please choose an image smaller than 5MB."),
).toBeInTheDocument();
// The load-bearing assertion: an oversized file must not reach the cropper or the upload.
expect(screen.queryByTestId("cropper")).not.toBeInTheDocument();
expect(h.upload).not.toHaveBeenCalled();
});
it("lets a file at exactly the limit through to the cropper", () => {
renderCard();
pickFile(h.maxBytes);
expect(screen.getByTestId("cropper")).toBeInTheDocument();
expect(
screen.queryByText("Please choose an image smaller than 5MB."),
).not.toBeInTheDocument();
});
it("clears a previous size error once an acceptable file is picked", () => {
renderCard();
pickFile(h.maxBytes + 1);
expect(
screen.getByText("Please choose an image smaller than 5MB."),
).toBeInTheDocument();
pickFile(1024);
expect(
screen.queryByText("Please choose an image smaller than 5MB."),
).not.toBeInTheDocument();
});
});
describe("ProfilePictureCard - error surface", () => {
it("shows the server's reason when the upload is rejected", async () => {
h.upload.mockRejectedValue({
response: { data: { message: "Unsupported image format" } },
});
renderCard();
pickFile(1024);
await finishCrop();
expect(screen.getByText("Unsupported image format")).toBeInTheDocument();
expect(h.refresh).not.toHaveBeenCalled();
});
it("falls back to a generic message when the upload failure carries none", async () => {
h.upload.mockRejectedValue(new Error("network"));
renderCard();
pickFile(1024);
await finishCrop();
expect(
screen.getByText(
"Could not upload your profile picture. Please try again.",
),
).toBeInTheDocument();
});
it("surfaces a failed removal", async () => {
h.pictureUrl = "blob:avatar";
h.remove.mockRejectedValue(new Error("boom"));
renderCard();
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
// Removal is confirmed in a modal, so the destructive click is the one inside it.
const dialog = await screen.findByRole("dialog");
await act(async () => {
fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
});
expect(h.remove).toHaveBeenCalledTimes(1);
expect(
screen.getByText(
"Could not remove your profile picture. Please try again.",
),
).toBeInTheDocument();
});
it("refreshes every consumer after a successful upload", async () => {
h.upload.mockResolvedValue(undefined);
renderCard();
pickFile(1024);
await finishCrop();
expect(h.refresh).toHaveBeenCalledTimes(1);
});
});
@@ -1,11 +0,0 @@
import type { MemberAvatarSource } from "@portal/api/memberAvatars";
import { fetchAvatarThumbnails } from "@portal/api/users";
/**
* Self-hosted: avatar keys are backend user ids, resolved to data URLs by the batch endpoint (the
* portal's bearer-token transport rules out a plain image URL). Also what the portal test project
* resolves `@app/portal/memberAvatars` to, since it has no saas alias.
*/
export const memberAvatars: MemberAvatarSource = {
resolve: fetchAvatarThumbnails,
};
@@ -1,36 +0,0 @@
import type { MemberAvatarSource } from "@portal/api/memberAvatars";
import { supabase } from "@app/auth/supabase";
/** Same bucket the editor's account page writes to. */
const PROFILE_BUCKET = "profile-pictures";
/** Signed-URL lifetime for roster avatars; the roster is refetched well inside this. */
const AVATAR_URL_TTL_SECONDS = 60 * 60;
/**
* SaaS build: avatar keys are Supabase auth uuids and the picture lives at `<uuid>/avatar`. Storage
* RLS (migration V34) limits reads to teammates; paths outside the team error and are dropped.
*/
export const memberAvatars: MemberAvatarSource = {
async resolve(keys: string[]): Promise<Record<string, string>> {
const ids = Array.from(new Set(keys)).filter(Boolean);
if (ids.length === 0) return {};
try {
const { data, error } = await supabase.storage
.from(PROFILE_BUCKET)
.createSignedUrls(
ids.map((id) => `${id}/avatar`),
AVATAR_URL_TTL_SECONDS,
);
if (error || !data) return {};
const byId: Record<string, string> = {};
for (const entry of data) {
if (entry.error || !entry.signedUrl || !entry.path) continue;
byId[entry.path.split("/")[0]] = entry.signedUrl;
}
return byId;
} catch {
return {};
}
},
};
@@ -1,5 +1,4 @@
import type { UsersBackend } from "@portal/api/usersBackend";
import { memberAvatars } from "@app/portal/memberAvatars";
import { apiClient } from "@portal/api/http";
import { tryGetPortalQueryClient } from "@portal/queryClient";
import { qk } from "@portal/queries/keys";
@@ -44,8 +43,6 @@ interface TeamMemberDTO {
/** "LEADER" | "MEMBER". */
role: string;
joinedAt?: string;
/** Supabase auth uuid; the avatar lives at `<uuid>/avatar` in the profile-pictures bucket. */
supabaseAuthId?: string | null;
}
interface InvitationDTO {
@@ -170,17 +167,6 @@ export const usersBackend: UsersBackend = {
: [];
const members = (memberDtos ?? []).map((m) => toMember(m, team));
const avatarUrls = await memberAvatars.resolve(
(memberDtos ?? [])
.map((m) => m.supabaseAuthId)
.filter((id): id is string => Boolean(id)),
);
for (const [index, dto] of (memberDtos ?? []).entries()) {
const avatarUrl = dto.supabaseAuthId
? avatarUrls[dto.supabaseAuthId]
: undefined;
if (avatarUrl) members[index].avatarUrl = avatarUrl;
}
// Only genuinely-live invites: PENDING, and not past expiry. The backend
// returns every status and flips PENDING->EXPIRED on a daily sweep, so a
// past-expiry PENDING row can linger for up to a day - drop it here.