continuous-integration/drone/pr Build is passing
- Роль ADMIN = users.is_admin; встроенный администратор (mailserver.admins, по
умолчанию admin@) всегда ADMIN и получает роль в базе при старте.
- PUT /api/admin/mailboxes/{id}/role {ADMIN|USER}: только ADMIN; не свою, не
встроенного, общий ящик не может быть ADMIN.
- Admin panel (/admin, только ADMIN): USERS (логины и роли, MAKE ADMIN / MAKE USER),
MAILBOXES, LISTS. User panel (/settings): профиль, пароль, хранилище, вид, мои ящики.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
14 KiB
Java
249 lines
14 KiB
Java
package com.mailserver.controller;
|
|
|
|
import com.mailserver.config.MailAdmins;
|
|
import com.mailserver.config.MailDomains;
|
|
import com.mailserver.domain.User;
|
|
import com.mailserver.service.MailService;
|
|
import com.mailserver.service.UserService;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
import org.springframework.security.core.Authentication;
|
|
import org.springframework.web.server.ResponseStatusException;
|
|
import reactor.core.publisher.Flux;
|
|
import reactor.core.publisher.Mono;
|
|
|
|
import java.util.List;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.anyLong;
|
|
import static org.mockito.ArgumentMatchers.anyString;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.Mockito.mock;
|
|
import static org.mockito.Mockito.never;
|
|
import static org.mockito.Mockito.verify;
|
|
import static org.mockito.Mockito.when;
|
|
|
|
/** Settings → Mailboxes: only administrators; mailboxes only in our domains; a generated password comes back once. */
|
|
class MailboxAdminControllerTest {
|
|
|
|
private final UserService users = mock(UserService.class);
|
|
private final MailService mail = mock(MailService.class);
|
|
private final MailDomains domains = new MailDomains("jambocheck.com", "");
|
|
private final MailAdmins admins = new MailAdmins("", "", domains);
|
|
private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class);
|
|
private final com.mailserver.service.MailLists lists = mock(com.mailserver.service.MailLists.class);
|
|
private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class);
|
|
private final com.mailserver.service.MailDirectory directory = new com.mailserver.service.MailDirectory(users, lists, domains, access);
|
|
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt, directory, lists, access);
|
|
|
|
private final User admin = user(1L, "admin@jambocheck.com");
|
|
private final User alice = user(2L, "alice@jambocheck.com");
|
|
|
|
MailboxAdminControllerTest() {
|
|
when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L));
|
|
when(lists.exists(anyString())).thenReturn(Mono.just(false));
|
|
when(access.members(anyLong())).thenReturn(Flux.empty());
|
|
when(lists.exists("alerts@jambocheck.com")).thenReturn(Mono.just(true));
|
|
when(users.findOptional(anyString())).thenReturn(Mono.empty());
|
|
when(users.findOptional("alice@jambocheck.com")).thenReturn(Mono.just(alice));
|
|
when(lists.save(anyString(), any(), any(), any(Boolean.class), any())).thenAnswer(i ->
|
|
Mono.just(new com.mailserver.service.MailLists.MailList(5L, i.getArgument(0), i.getArgument(1), i.getArgument(2),
|
|
i.getArgument(3), List.of("alice@jambocheck.com"))));
|
|
when(users.all()).thenReturn(Flux.just(alice, admin));
|
|
when(users.byId(1L)).thenReturn(Mono.just(admin));
|
|
when(users.byId(2L)).thenReturn(Mono.just(alice));
|
|
when(users.byId(99L)).thenReturn(Mono.empty());
|
|
when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0))));
|
|
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
|
|
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
|
|
User u = i.getArgument(0);
|
|
u.setActive(i.getArgument(1));
|
|
return Mono.just(u);
|
|
});
|
|
when(users.setTrap(any(), any(Boolean.class))).thenAnswer(i -> {
|
|
User u = i.getArgument(0);
|
|
u.setTrap(i.getArgument(1));
|
|
return Mono.just(u);
|
|
});
|
|
}
|
|
|
|
@Test
|
|
void mailboxAndListShareOneAddressSpaceNoPlusInNames() {
|
|
assertThat(status(() -> create("admin@jambocheck.com", "alerts@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT);
|
|
assertThat(status(() -> create("admin@jambocheck.com", "bob+x@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
|
|
assertThat(create("admin@jambocheck.com", "jambotron_prod@jambocheck.com", "").mailbox().email()).isEqualTo("jambotron_prod@jambocheck.com");
|
|
|
|
var request = new MailboxAdminController.ListRequest("prod", "alerts", false, List.of("Alice@jambocheck.com"));
|
|
assertThat(controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(request)).block().members())
|
|
.containsExactly("alice@jambocheck.com");
|
|
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alice@jambocheck.com", Mono.just(request)).block()))
|
|
.isEqualTo(HttpStatus.CONFLICT);
|
|
var unknown = new MailboxAdminController.ListRequest("prod", null, false, List.of("ghost@jambocheck.com"));
|
|
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(unknown)).block()))
|
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
|
var badFolder = new MailboxAdminController.ListRequest("Trash", null, false, List.of());
|
|
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(badFolder)).block()))
|
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
|
assertThat(status(() -> controller.saveList(as("alice@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(request)).block()))
|
|
.isEqualTo(HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
@Test
|
|
void sharedMailboxHasNoPasswordShownMembersAreSet() {
|
|
when(users.setShared(any(), any(Boolean.class))).thenAnswer(i -> {
|
|
User u = i.getArgument(0);
|
|
u.setShared(i.getArgument(1));
|
|
return Mono.just(u);
|
|
});
|
|
var created = controller.create(as("admin@jambocheck.com"),
|
|
Mono.just(new MailboxAdminController.CreateRequest("support@jambocheck.com", "Support", "", true))).block();
|
|
assertThat(created.password()).isNull();
|
|
assertThat(created.mailbox().shared()).isTrue();
|
|
|
|
when(access.setMembers(anyLong(), any())).thenReturn(Mono.empty());
|
|
controller.members(as("admin@jambocheck.com"), 2L,
|
|
Mono.just(new MailboxAdminController.MembersRequest(List.of("Alice@jambocheck.com")))).block();
|
|
verify(access).setMembers(eq(2L), eq(List.of(2L)));
|
|
assertThat(status(() -> controller.members(as("admin@jambocheck.com"), 2L,
|
|
Mono.just(new MailboxAdminController.MembersRequest(List.of("ghost@jambocheck.com")))).block())).isEqualTo(HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
@Test
|
|
void administratorSwitchesTrapOthersCanNot() {
|
|
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue();
|
|
assertThat(alice.isTrap()).isTrue();
|
|
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(false))).block().trap()).isFalse();
|
|
assertThat(status(() -> controller.trap(as("alice@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block()))
|
|
.isEqualTo(HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
@Test
|
|
void administratorIsTheFirstMailboxByDefault() {
|
|
assertThat(admins.isAdmin("ADMIN@jambocheck.com")).isTrue();
|
|
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
|
|
assertThat(new MailAdmins("ops@jambocheck.com, root@jambocheck.com", "admin@jambocheck.com", domains).isAdmin("admin@jambocheck.com")).isFalse();
|
|
}
|
|
|
|
@Test
|
|
void listsMailboxesSortedForAnAdministratorOnly() {
|
|
List<MailboxAdminController.MailboxView> list = controller.list(as("admin@jambocheck.com")).collectList().block();
|
|
assertThat(list).extracting(MailboxAdminController.MailboxView::email).containsExactly("admin@jambocheck.com", "alice@jambocheck.com");
|
|
assertThat(list.get(0).admin()).isTrue();
|
|
|
|
assertThat(status(() -> controller.list(as("alice@jambocheck.com")).collectList().block())).isEqualTo(HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
@Test
|
|
void createsReservedNamesWithAGeneratedPasswordShownOnce() {
|
|
var created = controller.create(as("admin@jambocheck.com"),
|
|
Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", "", null))).block();
|
|
|
|
assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com");
|
|
assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20);
|
|
verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron"));
|
|
|
|
var chosen = controller.create(as("admin@jambocheck.com"),
|
|
Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1", null))).block();
|
|
assertThat(chosen.password()).isNull();
|
|
}
|
|
|
|
@Test
|
|
void refusesOtherDomainsBadAddressesShortPasswordsAndNonAdmins() {
|
|
assertThat(status(() -> create("admin@jambocheck.com", "x@gmail.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
|
|
assertThat(status(() -> create("admin@jambocheck.com", "bad address@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
|
|
assertThat(status(() -> create("admin@jambocheck.com", "x@jambocheck.com", "short"))).isEqualTo(HttpStatus.BAD_REQUEST);
|
|
assertThat(status(() -> create("alice@jambocheck.com", "x@jambocheck.com", ""))).isEqualTo(HttpStatus.FORBIDDEN);
|
|
verify(users, never()).register(anyString(), anyString(), any());
|
|
|
|
when(users.register(eq("alice@jambocheck.com"), anyString(), any())).thenReturn(Mono.error(new IllegalArgumentException("exists")));
|
|
assertThat(status(() -> create("admin@jambocheck.com", "alice@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT);
|
|
}
|
|
|
|
@Test
|
|
void newPasswordAndSwitchOffButNotYourself() {
|
|
var reset = controller.password(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.PasswordRequest(null))).block();
|
|
assertThat(reset.password()).isNotBlank();
|
|
verify(users).setPassword(alice, reset.password());
|
|
|
|
assertThat(controller.active(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block().active()).isFalse();
|
|
assertThat(status(() -> controller.active(as("admin@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block()))
|
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
@Test
|
|
void administratorDeletesAMailboxButNotHisOwnOthersCanNot() {
|
|
when(mail.deleteMailbox(any())).thenReturn(Mono.empty());
|
|
|
|
controller.delete(as("admin@jambocheck.com"), 2L).block();
|
|
verify(mail).deleteMailbox(alice);
|
|
|
|
assertThat(status(() -> controller.delete(as("admin@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.BAD_REQUEST);
|
|
assertThat(status(() -> controller.delete(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN);
|
|
assertThat(status(() -> controller.delete(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND);
|
|
verify(mail, never()).deleteMailbox(admin);
|
|
}
|
|
|
|
@Test
|
|
void administratorGivesAndTakesRoleAdminNotOwnNotBuiltInNotShared() {
|
|
when(users.setAdmin(any(), any(Boolean.class))).thenAnswer(i -> {
|
|
User u = i.getArgument(0);
|
|
u.setAdmin(i.getArgument(1));
|
|
return Mono.just(u);
|
|
});
|
|
var made = controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block();
|
|
assertThat(made.role()).isEqualTo("ADMIN");
|
|
assertThat(admins.isAdmin("alice@jambocheck.com")).isTrue();
|
|
// alice is ADMIN now — she manages roles too, but not the built-in one and not her own
|
|
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block()))
|
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
|
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block()))
|
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
|
var back = controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block();
|
|
assertThat(back.role()).isEqualTo("USER");
|
|
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
|
|
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block()))
|
|
.isEqualTo(HttpStatus.FORBIDDEN);
|
|
|
|
alice.setShared(true);
|
|
assertThat(status(() -> controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block()))
|
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
@Test
|
|
void administratorOpensAnyMailboxOthersCanNot() {
|
|
when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token");
|
|
|
|
var session = controller.open(as("admin@jambocheck.com"), 2L).block();
|
|
assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com");
|
|
assertThat(session.getToken()).isEqualTo("alice-token");
|
|
|
|
assertThat(status(() -> controller.open(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN);
|
|
assertThat(status(() -> controller.open(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND);
|
|
}
|
|
|
|
private MailboxAdminController.Created create(String me, String email, String password) {
|
|
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password, null))).block();
|
|
}
|
|
|
|
private static Mono<Authentication> as(String email) {
|
|
return Mono.just(new UsernamePasswordAuthenticationToken(email, null, List.of()));
|
|
}
|
|
|
|
private static HttpStatus status(Runnable call) {
|
|
Throwable error = org.assertj.core.api.Assertions.catchThrowable(call::run);
|
|
assertThat(error).isInstanceOf(ResponseStatusException.class);
|
|
return HttpStatus.valueOf(((ResponseStatusException) error).getStatusCode().value());
|
|
}
|
|
|
|
private static User user(Long id, String email) {
|
|
User user = new User();
|
|
user.setId(id);
|
|
user.setEmail(email);
|
|
user.setPassword("hash");
|
|
return user;
|
|
}
|
|
}
|