Merge commit '98a52a1ac23591fa1cd30b7b175da81ec607e521' into work/attendance

This commit is contained in:
sechmachine
2026-08-15 00:35:31 +07:00
17 changed files with 748 additions and 13 deletions
@@ -0,0 +1,184 @@
package com.lab.labtimesheet.feature.account.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class})
@SpringBootTest
@ActiveProfiles("test")
class AccountActivationIntegrationTest {
@Autowired
private BootstrapService bootstrap;
@Autowired
private AccountService accounts;
@Autowired
private SmtpConfigurationService smtp;
@Autowired
private RecordingSmtpProbe mail;
@Autowired
private AppUserRepository users;
@Autowired
private InternProfileRepository internProfiles;
@Autowired
private UserActionTokenRepository tokens;
@Autowired
private PasswordEncoder passwords;
@Test
void smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory() throws Exception {
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
long adminId = accounts.requireActiveAdminId("admin@example.com");
var mentor = new CreateAccountCommand(
" MENTOR@EXAMPLE.COM ", " Mentor One ", GlobalRole.MENTOR, null, null, null);
assertThatThrownBy(() -> accounts.create(mentor, adminId))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("SMTP");
assertThat(users.count()).isEqualTo(1);
activateSmtp(adminId);
mail.messages.clear();
var mentorCreation = accounts.create(mentor, adminId);
assertThat(mentorCreation.deliverySucceeded()).isTrue();
var pendingMentor = users.findById(mentorCreation.userId()).orElseThrow();
assertThat(pendingMentor.getEmail()).isEqualTo("mentor@example.com");
assertThat(pendingMentor.getDisplayName()).isEqualTo("Mentor One");
assertThat(pendingMentor.getGlobalRole()).isEqualTo(GlobalRole.MENTOR);
assertThat(pendingMentor.getAccountStatus()).isEqualTo(AccountStatus.PENDING_ACTIVATION);
assertThat(pendingMentor.getPasswordHash()).isNull();
String rawMentorToken = mail.onlyActivationToken();
var mentorToken = tokens.findAll().stream()
.filter(token -> token.getUserId().equals(mentorCreation.userId()))
.findFirst()
.orElseThrow();
assertThat(mentorToken.getPurpose()).isEqualTo(TokenPurpose.ACTIVATION);
assertThat(mentorToken.getTokenHash()).containsExactly(sha256(rawMentorToken));
assertThat(mentorToken.getExpiresAt()).isEqualTo(Instant.parse("2026-08-15T00:00:00Z"));
assertThat(mentorToken.isUsableAt(mentorToken.getExpiresAt())).isFalse();
assertThat(HexFormat.of().formatHex(mentorToken.getTokenHash())).doesNotContain(rawMentorToken);
assertThat(accounts.activate("not-the-token", "new secure mentor password")).isFalse();
assertThat(accounts.activate(rawMentorToken, "new secure mentor password")).isTrue();
assertThat(accounts.activate(rawMentorToken, "another secure password")).isFalse();
var activeMentor = users.findById(mentorCreation.userId()).orElseThrow();
assertThat(activeMentor.getAccountStatus()).isEqualTo(AccountStatus.ACTIVE);
assertThat(passwords.matches("new secure mentor password", activeMentor.getPasswordHash())).isTrue();
assertThat(tokens.findById(mentorToken.getId()).orElseThrow().getUsedAt()).isNotNull();
mail.fail = true;
var failedIntern = accounts.create(new CreateAccountCommand(
"intern-failed@example.com", "Failed Intern", GlobalRole.INTERN, "STU-FAIL",
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
assertThat(failedIntern.deliverySucceeded()).isFalse();
assertThat(users.findById(failedIntern.userId()).orElseThrow().getAccountStatus())
.isEqualTo(AccountStatus.PENDING_ACTIVATION);
assertThat(tokens.findAll().stream()
.filter(token -> token.getUserId().equals(failedIntern.userId()))
.findFirst().orElseThrow().getInvalidatedAt()).isNotNull();
mail.fail = false;
mail.messages.clear();
var activeInternCreation = accounts.create(new CreateAccountCommand(
"intern@example.com", "Active Intern", GlobalRole.INTERN, "STU-001",
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue();
accounts.activateInternship(activeInternCreation.userId(), adminId);
var profile = internProfiles.findById(activeInternCreation.userId()).orElseThrow();
assertThat(profile.getInternshipStatus()).isEqualTo(InternshipStatus.ACTIVE);
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 8, 1))).isTrue();
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 12, 31))).isTrue();
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2027, 1, 1))).isFalse();
var summary = accounts.summary();
assertThat(summary.activeAccounts()).isEqualTo(3);
assertThat(summary.pendingActivations()).isEqualTo(1);
assertThat(summary.activeInternships()).isEqualTo(1);
}
private void activateSmtp(long adminId) {
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
smtp.testDraft(draftId, adminId, "admin@example.com");
smtp.activate(draftId, adminId);
}
private static byte[] sha256(String value) throws Exception {
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
}
@TestConfiguration(proxyBeanMethods = false)
static class MailProbeConfiguration {
@Bean
@Primary
RecordingSmtpProbe recordingSmtpProbe() {
return new RecordingSmtpProbe();
}
}
static final class RecordingSmtpProbe implements SmtpProbe {
private final List<Message> messages = new ArrayList<>();
private boolean fail;
@Override
public void send(SmtpConnection connection, String recipient, String subject, String body) {
if (fail) {
throw new IllegalStateException("simulated SMTP failure");
}
messages.add(new Message(recipient, subject, body));
}
String onlyActivationToken() {
assertThat(messages).hasSize(1);
String body = messages.getFirst().body();
int tokenStart = body.indexOf("token=");
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
return body.substring(tokenStart + "token=".length()).trim();
}
}
record Message(String recipient, String subject, String body) {
}
}
+1
View File
@@ -3,5 +3,6 @@ spring:
compose:
enabled: false
lab:
public-origin: http://localhost:8080
security:
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=