feat(profile): add configurable profile visibility with access control
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
parent
c84377b05a
commit
102d04290b
9 changed files with 350 additions and 8 deletions
|
|
@ -14,6 +14,7 @@ import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import net.javahippie.fitpub.service.FederationService;
|
import net.javahippie.fitpub.service.FederationService;
|
||||||
|
import net.javahippie.fitpub.service.ProfileAccessService;
|
||||||
import net.javahippie.fitpub.service.WebFingerClient;
|
import net.javahippie.fitpub.service.WebFingerClient;
|
||||||
import net.javahippie.fitpub.service.UserService;
|
import net.javahippie.fitpub.service.UserService;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
@ -47,6 +48,7 @@ public class UserController {
|
||||||
private final WebFingerClient webFingerClient;
|
private final WebFingerClient webFingerClient;
|
||||||
private final FederationService federationService;
|
private final FederationService federationService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final ProfileAccessService profileAccessService;
|
||||||
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
|
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
|
|
@ -68,6 +70,14 @@ public class UserController {
|
||||||
dto.setFollowingCount((long) followingCount);
|
dto.setFollowingCount((long) followingCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private User getCurrentUserOrNull(UserDetails userDetails) {
|
||||||
|
if (userDetails == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return userRepository.findByUsername(userDetails.getUsername()).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user's profile.
|
* Get current user's profile.
|
||||||
*
|
*
|
||||||
|
|
@ -111,6 +121,9 @@ public class UserController {
|
||||||
if (request.getBio() != null) {
|
if (request.getBio() != null) {
|
||||||
user.setBio(request.getBio().trim());
|
user.setBio(request.getBio().trim());
|
||||||
}
|
}
|
||||||
|
if (request.getProfileVisibility() != null) {
|
||||||
|
user.setProfileVisibility(request.getProfileVisibility());
|
||||||
|
}
|
||||||
if (request.getAvatarUrl() != null) {
|
if (request.getAvatarUrl() != null) {
|
||||||
user.setAvatarUrl(request.getAvatarUrl().trim());
|
user.setAvatarUrl(request.getAvatarUrl().trim());
|
||||||
}
|
}
|
||||||
|
|
@ -177,13 +190,21 @@ public class UserController {
|
||||||
* @return user profile
|
* @return user profile
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{username}")
|
@GetMapping("/{username}")
|
||||||
public ResponseEntity<UserDTO> getUserByUsername(@PathVariable String username) {
|
public ResponseEntity<UserDTO> getUserByUsername(
|
||||||
|
@PathVariable String username,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails
|
||||||
|
) {
|
||||||
log.debug("Retrieving profile for username: {}", username);
|
log.debug("Retrieving profile for username: {}", username);
|
||||||
|
|
||||||
User user = userRepository.findByUsername(username)
|
User user = userRepository.findByUsername(username)
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||||
|
|
||||||
UserDTO dto = UserDTO.fromEntity(user);
|
User viewer = getCurrentUserOrNull(userDetails);
|
||||||
|
profileAccessService.requireProfileAccess(user, viewer);
|
||||||
|
|
||||||
|
UserDTO dto = viewer != null && viewer.getId().equals(user.getId())
|
||||||
|
? UserDTO.fromEntity(user)
|
||||||
|
: UserDTO.fromEntityPublic(user);
|
||||||
populateSocialCounts(dto, user);
|
populateSocialCounts(dto, user);
|
||||||
|
|
||||||
return ResponseEntity.ok(dto);
|
return ResponseEntity.ok(dto);
|
||||||
|
|
@ -196,13 +217,21 @@ public class UserController {
|
||||||
* @return user profile
|
* @return user profile
|
||||||
*/
|
*/
|
||||||
@GetMapping("/id/{id}")
|
@GetMapping("/id/{id}")
|
||||||
public ResponseEntity<UserDTO> getUserById(@PathVariable UUID id) {
|
public ResponseEntity<UserDTO> getUserById(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails
|
||||||
|
) {
|
||||||
log.debug("Retrieving profile for user ID: {}", id);
|
log.debug("Retrieving profile for user ID: {}", id);
|
||||||
|
|
||||||
User user = userRepository.findById(id)
|
User user = userRepository.findById(id)
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||||
|
|
||||||
UserDTO dto = UserDTO.fromEntity(user);
|
User viewer = getCurrentUserOrNull(userDetails);
|
||||||
|
profileAccessService.requireProfileAccess(user, viewer);
|
||||||
|
|
||||||
|
UserDTO dto = viewer != null && viewer.getId().equals(user.getId())
|
||||||
|
? UserDTO.fromEntity(user)
|
||||||
|
: UserDTO.fromEntityPublic(user);
|
||||||
populateSocialCounts(dto, user);
|
populateSocialCounts(dto, user);
|
||||||
|
|
||||||
return ResponseEntity.ok(dto);
|
return ResponseEntity.ok(dto);
|
||||||
|
|
@ -623,13 +652,17 @@ public class UserController {
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{username}/peaks")
|
@GetMapping("/{username}/peaks")
|
||||||
public ResponseEntity<java.util.List<Map<String, Object>>> getUserPeaks(
|
public ResponseEntity<java.util.List<Map<String, Object>>> getUserPeaks(
|
||||||
@PathVariable String username
|
@PathVariable String username,
|
||||||
|
@AuthenticationPrincipal UserDetails userDetails
|
||||||
) {
|
) {
|
||||||
User user = userRepository.findByUsername(username).orElse(null);
|
User user = userRepository.findByUsername(username).orElse(null);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
User viewer = getCurrentUserOrNull(userDetails);
|
||||||
|
profileAccessService.requireProfileAccess(user, viewer);
|
||||||
|
|
||||||
var projections = activityPeakRepository.findPeaksVisitedByUser(user.getId());
|
var projections = activityPeakRepository.findPeaksVisitedByUser(user.getId());
|
||||||
var result = projections.stream()
|
var result = projections.stream()
|
||||||
.map(p -> {
|
.map(p -> {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ public class UserDTO {
|
||||||
private String email; // Only shown to the user themselves
|
private String email; // Only shown to the user themselves
|
||||||
private String displayName;
|
private String displayName;
|
||||||
private String bio;
|
private String bio;
|
||||||
|
private User.ProfileVisibility profileVisibility;
|
||||||
private String avatarUrl;
|
private String avatarUrl;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
@ -52,6 +53,7 @@ public class UserDTO {
|
||||||
.email(user.getEmail())
|
.email(user.getEmail())
|
||||||
.displayName(user.getDisplayName())
|
.displayName(user.getDisplayName())
|
||||||
.bio(user.getBio())
|
.bio(user.getBio())
|
||||||
|
.profileVisibility(user.getProfileVisibility())
|
||||||
.avatarUrl(user.getAvatarUrl())
|
.avatarUrl(user.getAvatarUrl())
|
||||||
.homeLatitude(user.getHomeLatitude())
|
.homeLatitude(user.getHomeLatitude())
|
||||||
.homeLongitude(user.getHomeLongitude())
|
.homeLongitude(user.getHomeLongitude())
|
||||||
|
|
@ -72,6 +74,7 @@ public class UserDTO {
|
||||||
.username(user.getUsername())
|
.username(user.getUsername())
|
||||||
.displayName(user.getDisplayName())
|
.displayName(user.getDisplayName())
|
||||||
.bio(user.getBio())
|
.bio(user.getBio())
|
||||||
|
.profileVisibility(user.getProfileVisibility())
|
||||||
.avatarUrl(user.getAvatarUrl())
|
.avatarUrl(user.getAvatarUrl())
|
||||||
.createdAt(user.getCreatedAt())
|
.createdAt(user.getCreatedAt())
|
||||||
.updatedAt(user.getUpdatedAt())
|
.updatedAt(user.getUpdatedAt())
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
import org.hibernate.validator.constraints.URL;
|
import org.hibernate.validator.constraints.URL;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,6 +25,8 @@ public class UserUpdateRequest {
|
||||||
@Size(max = 500, message = "Bio must not exceed 500 characters")
|
@Size(max = 500, message = "Bio must not exceed 500 characters")
|
||||||
private String bio;
|
private String bio;
|
||||||
|
|
||||||
|
private User.ProfileVisibility profileVisibility;
|
||||||
|
|
||||||
@URL(message = "Avatar URL must be a valid URL")
|
@URL(message = "Avatar URL must be a valid URL")
|
||||||
private String avatarUrl;
|
private String avatarUrl;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ public class User {
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String bio;
|
private String bio;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "profile_visibility", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private ProfileVisibility profileVisibility = ProfileVisibility.FOLLOWERS;
|
||||||
|
|
||||||
@Column(name = "avatar_url")
|
@Column(name = "avatar_url")
|
||||||
private String avatarUrl;
|
private String avatarUrl;
|
||||||
|
|
||||||
|
|
@ -112,4 +117,10 @@ public class User {
|
||||||
public String getWebFingerAccount(String domain) {
|
public String getWebFingerAccount(String domain) {
|
||||||
return String.format("acct:%s@%s", username, domain);
|
return String.format("acct:%s@%s", username, domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ProfileVisibility {
|
||||||
|
PUBLIC,
|
||||||
|
FOLLOWERS,
|
||||||
|
PRIVATE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import net.javahippie.fitpub.model.entity.Follow;
|
||||||
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central access policy for profile visibility checks.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProfileAccessService {
|
||||||
|
|
||||||
|
private final FollowRepository followRepository;
|
||||||
|
|
||||||
|
@Value("${fitpub.base-url}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
public boolean canViewProfile(User profileOwner, User viewer) {
|
||||||
|
if (viewer != null && viewer.getId().equals(profileOwner.getId())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
User.ProfileVisibility visibility = profileOwner.getProfileVisibility() != null
|
||||||
|
? profileOwner.getProfileVisibility()
|
||||||
|
: User.ProfileVisibility.PUBLIC;
|
||||||
|
|
||||||
|
if (visibility == User.ProfileVisibility.PUBLIC) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibility == User.ProfileVisibility.PRIVATE || viewer == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String actorUri = profileOwner.getActorUri(baseUrl);
|
||||||
|
return followRepository.findByFollowerIdAndFollowingActorUri(viewer.getId(), actorUri)
|
||||||
|
.filter(follow -> follow.getStatus() == Follow.FollowStatus.ACCEPTED)
|
||||||
|
.isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAccessDeniedMessage(User profileOwner) {
|
||||||
|
return profileOwner.getProfileVisibility() == User.ProfileVisibility.FOLLOWERS
|
||||||
|
? "This profile is only visible to followers."
|
||||||
|
: "This profile is private.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireProfileAccess(User profileOwner, User viewer) {
|
||||||
|
if (!canViewProfile(profileOwner, viewer)) {
|
||||||
|
throw new ResponseStatusException(FORBIDDEN, getAccessDeniedMessage(profileOwner));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN profile_visibility VARCHAR(20) NOT NULL DEFAULT 'FOLLOWERS';
|
||||||
|
|
@ -45,6 +45,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="profileVisibility" class="form-label">Profile Visibility</label>
|
||||||
|
<select class="form-select" id="profileVisibility" name="profileVisibility">
|
||||||
|
<option value="PUBLIC">Public - Anyone can see</option>
|
||||||
|
<option value="FOLLOWERS">Followers Only - Only your followers can see</option>
|
||||||
|
<option value="PRIVATE">Private - Only you can see</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Controls who can view your profile page.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Avatar URL -->
|
<!-- Avatar URL -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="avatarUrl" class="form-label">Avatar URL</label>
|
<label for="avatarUrl" class="form-label">Avatar URL</label>
|
||||||
|
|
@ -254,6 +264,7 @@
|
||||||
const formData = {
|
const formData = {
|
||||||
displayName: document.getElementById('displayName').value.trim(),
|
displayName: document.getElementById('displayName').value.trim(),
|
||||||
bio: document.getElementById('bio').value.trim(),
|
bio: document.getElementById('bio').value.trim(),
|
||||||
|
profileVisibility: document.getElementById('profileVisibility').value,
|
||||||
avatarUrl: document.getElementById('avatarUrl').value.trim(),
|
avatarUrl: document.getElementById('avatarUrl').value.trim(),
|
||||||
homeLatitude: document.getElementById('homeLatitude').value ? parseFloat(document.getElementById('homeLatitude').value) : null,
|
homeLatitude: document.getElementById('homeLatitude').value ? parseFloat(document.getElementById('homeLatitude').value) : null,
|
||||||
homeLongitude: document.getElementById('homeLongitude').value ? parseFloat(document.getElementById('homeLongitude').value) : null,
|
homeLongitude: document.getElementById('homeLongitude').value ? parseFloat(document.getElementById('homeLongitude').value) : null,
|
||||||
|
|
@ -320,6 +331,7 @@
|
||||||
function populateForm(user) {
|
function populateForm(user) {
|
||||||
document.getElementById('displayName').value = user.displayName || '';
|
document.getElementById('displayName').value = user.displayName || '';
|
||||||
document.getElementById('bio').value = user.bio || '';
|
document.getElementById('bio').value = user.bio || '';
|
||||||
|
document.getElementById('profileVisibility').value = user.profileVisibility || 'FOLLOWERS';
|
||||||
document.getElementById('avatarUrl').value = user.avatarUrl || '';
|
document.getElementById('avatarUrl').value = user.avatarUrl || '';
|
||||||
document.getElementById('email').value = user.email || '';
|
document.getElementById('email').value = user.email || '';
|
||||||
document.getElementById('username').value = user.username || '';
|
document.getElementById('username').value = user.username || '';
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@
|
||||||
<span id="errorMessage"></span>
|
<span id="errorMessage"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="accessNotice" class="alert alert-info d-none" role="alert">
|
||||||
|
<i class="bi bi-shield-lock"></i>
|
||||||
|
<span id="accessNoticeMessage"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Profile Content -->
|
<!-- Profile Content -->
|
||||||
<div id="profileContent" class="d-none">
|
<div id="profileContent" class="d-none">
|
||||||
<!-- Profile Header -->
|
<!-- Profile Header -->
|
||||||
|
|
@ -159,7 +164,13 @@
|
||||||
fetch(`/api/users/${targetUsername}`)
|
fetch(`/api/users/${targetUsername}`)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('User not found');
|
return response.json()
|
||||||
|
.catch(() => ({}))
|
||||||
|
.then(errorData => {
|
||||||
|
const error = new Error(errorData.message || 'User not found');
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
|
|
@ -171,8 +182,13 @@
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error loading profile:', error);
|
console.error('Error loading profile:', error);
|
||||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||||
document.getElementById('errorMessage').textContent = 'User not found or profile could not be loaded.';
|
if (error.status === 403) {
|
||||||
document.getElementById('errorAlert').classList.remove('d-none');
|
document.getElementById('accessNoticeMessage').textContent = error.message;
|
||||||
|
document.getElementById('accessNotice').classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
document.getElementById('errorMessage').textContent = 'User not found or profile could not be loaded.';
|
||||||
|
document.getElementById('errorAlert').classList.remove('d-none');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import net.javahippie.fitpub.model.entity.Follow;
|
||||||
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||||
|
|
||||||
|
@DisplayName("ProfileAccessService Tests")
|
||||||
|
class ProfileAccessServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PUBLIC profile should be visible anonymously")
|
||||||
|
void publicProfileShouldBeVisibleAnonymously() {
|
||||||
|
AtomicInteger lookupCount = new AtomicInteger();
|
||||||
|
ProfileAccessService service = createService(Optional.empty(), lookupCount);
|
||||||
|
User owner = user("owner-public", User.ProfileVisibility.PUBLIC);
|
||||||
|
|
||||||
|
assertTrue(service.canViewProfile(owner, null));
|
||||||
|
assertEquals(0, lookupCount.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PUBLIC profile should be visible to another authenticated user")
|
||||||
|
void publicProfileShouldBeVisibleToAnotherAuthenticatedUser() {
|
||||||
|
AtomicInteger lookupCount = new AtomicInteger();
|
||||||
|
ProfileAccessService service = createService(Optional.empty(), lookupCount);
|
||||||
|
User owner = user("owner-public-auth", User.ProfileVisibility.PUBLIC);
|
||||||
|
User viewer = user("viewer-public-auth", User.ProfileVisibility.PUBLIC);
|
||||||
|
|
||||||
|
assertTrue(service.canViewProfile(owner, viewer));
|
||||||
|
assertEquals(0, lookupCount.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FOLLOWERS profile should be forbidden anonymously")
|
||||||
|
void followersProfileShouldBeForbiddenAnonymously() {
|
||||||
|
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
|
||||||
|
User owner = user("owner-followers-anon", User.ProfileVisibility.FOLLOWERS);
|
||||||
|
|
||||||
|
assertFalse(service.canViewProfile(owner, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FOLLOWERS profile should be forbidden to non followers")
|
||||||
|
void followersProfileShouldBeForbiddenToNonFollowers() {
|
||||||
|
AtomicInteger lookupCount = new AtomicInteger();
|
||||||
|
ProfileAccessService service = createService(Optional.empty(), lookupCount);
|
||||||
|
User owner = user("owner-followers-nonf", User.ProfileVisibility.FOLLOWERS);
|
||||||
|
User viewer = user("viewer-followers-nonf", User.ProfileVisibility.PUBLIC);
|
||||||
|
|
||||||
|
assertFalse(service.canViewProfile(owner, viewer));
|
||||||
|
assertEquals(1, lookupCount.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FOLLOWERS profile should be visible to accepted followers")
|
||||||
|
void followersProfileShouldBeVisibleToAcceptedFollowers() {
|
||||||
|
AtomicInteger lookupCount = new AtomicInteger();
|
||||||
|
ProfileAccessService service = createService(
|
||||||
|
Optional.of(Follow.builder().status(Follow.FollowStatus.ACCEPTED).build()),
|
||||||
|
lookupCount
|
||||||
|
);
|
||||||
|
User owner = user("owner-followers-accepted", User.ProfileVisibility.FOLLOWERS);
|
||||||
|
User viewer = user("viewer-followers-accepted", User.ProfileVisibility.PUBLIC);
|
||||||
|
|
||||||
|
assertTrue(service.canViewProfile(owner, viewer));
|
||||||
|
assertEquals(1, lookupCount.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FOLLOWERS profile should be forbidden to pending followers")
|
||||||
|
void followersProfileShouldBeForbiddenToPendingFollowers() {
|
||||||
|
AtomicInteger lookupCount = new AtomicInteger();
|
||||||
|
ProfileAccessService service = createService(
|
||||||
|
Optional.of(Follow.builder().status(Follow.FollowStatus.PENDING).build()),
|
||||||
|
lookupCount
|
||||||
|
);
|
||||||
|
User owner = user("owner-followers-pending", User.ProfileVisibility.FOLLOWERS);
|
||||||
|
User viewer = user("viewer-followers-pending", User.ProfileVisibility.PUBLIC);
|
||||||
|
|
||||||
|
assertFalse(service.canViewProfile(owner, viewer));
|
||||||
|
assertEquals(1, lookupCount.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PRIVATE profile should be forbidden anonymously")
|
||||||
|
void privateProfileShouldBeForbiddenAnonymously() {
|
||||||
|
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
|
||||||
|
User owner = user("owner-private-anon", User.ProfileVisibility.PRIVATE);
|
||||||
|
|
||||||
|
assertFalse(service.canViewProfile(owner, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PRIVATE profile should be forbidden to another authenticated user")
|
||||||
|
void privateProfileShouldBeForbiddenToAnotherAuthenticatedUser() {
|
||||||
|
AtomicInteger lookupCount = new AtomicInteger();
|
||||||
|
ProfileAccessService service = createService(Optional.empty(), lookupCount);
|
||||||
|
User owner = user("owner-private-other", User.ProfileVisibility.PRIVATE);
|
||||||
|
User viewer = user("viewer-private-other", User.ProfileVisibility.PUBLIC);
|
||||||
|
|
||||||
|
assertFalse(service.canViewProfile(owner, viewer));
|
||||||
|
assertEquals(0, lookupCount.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Owner should always be able to view own profile")
|
||||||
|
void ownerShouldAlwaysBeAbleToViewOwnProfile() {
|
||||||
|
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
|
||||||
|
|
||||||
|
for (User.ProfileVisibility visibility : User.ProfileVisibility.values()) {
|
||||||
|
User owner = user("self-" + visibility.name().toLowerCase(), visibility);
|
||||||
|
assertTrue(service.canViewProfile(owner, owner));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Require profile access should throw forbidden with followers message")
|
||||||
|
void requireProfileAccessShouldThrowForbiddenWithFollowersMessage() {
|
||||||
|
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
|
||||||
|
User owner = user("owner-followers-msg", User.ProfileVisibility.FOLLOWERS);
|
||||||
|
User viewer = user("viewer-followers-msg", User.ProfileVisibility.PUBLIC);
|
||||||
|
|
||||||
|
ResponseStatusException exception = assertThrows(
|
||||||
|
ResponseStatusException.class,
|
||||||
|
() -> service.requireProfileAccess(owner, viewer)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(FORBIDDEN, exception.getStatusCode());
|
||||||
|
assertEquals("This profile is only visible to followers.", exception.getReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Require profile access should throw forbidden with private message")
|
||||||
|
void requireProfileAccessShouldThrowForbiddenWithPrivateMessage() {
|
||||||
|
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
|
||||||
|
User owner = user("owner-private-msg", User.ProfileVisibility.PRIVATE);
|
||||||
|
User viewer = user("viewer-private-msg", User.ProfileVisibility.PUBLIC);
|
||||||
|
|
||||||
|
ResponseStatusException exception = assertThrows(
|
||||||
|
ResponseStatusException.class,
|
||||||
|
() -> service.requireProfileAccess(owner, viewer)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(FORBIDDEN, exception.getStatusCode());
|
||||||
|
assertEquals("This profile is private.", exception.getReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Require profile access should allow visible profiles")
|
||||||
|
void requireProfileAccessShouldAllowVisibleProfiles() {
|
||||||
|
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
|
||||||
|
User owner = user("owner-public-visible", User.ProfileVisibility.PUBLIC);
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> service.requireProfileAccess(owner, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProfileAccessService createService(Optional<Follow> followLookupResult, AtomicInteger lookupCount) {
|
||||||
|
FollowRepository repository = (FollowRepository) Proxy.newProxyInstance(
|
||||||
|
FollowRepository.class.getClassLoader(),
|
||||||
|
new Class[]{FollowRepository.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("findByFollowerIdAndFollowingActorUri".equals(method.getName())) {
|
||||||
|
lookupCount.incrementAndGet();
|
||||||
|
return followLookupResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnsupportedOperationException("Unexpected repository method: " + method.getName());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ProfileAccessService service = new ProfileAccessService(repository);
|
||||||
|
ReflectionTestUtils.setField(service, "baseUrl", "http://localhost:8080");
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private User user(String username, User.ProfileVisibility visibility) {
|
||||||
|
return User.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.username(username)
|
||||||
|
.email(username + "@example.com")
|
||||||
|
.passwordHash("hash")
|
||||||
|
.publicKey("pub")
|
||||||
|
.privateKey("priv")
|
||||||
|
.profileVisibility(visibility)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue