init: 클린 기반 auth 서버 설계
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
package com.project.auth.application.auth.exception;
|
||||
|
||||
import com.project.auth.application.support.exception.AuthErrorCode;
|
||||
import com.project.auth.application.support.exception.BusinessException;
|
||||
|
||||
public class InvalidKeycloakClaimsException extends BusinessException {
|
||||
|
||||
public InvalidKeycloakClaimsException() {
|
||||
super(AuthErrorCode.KEYCLOAK_CLAIMS_INVALID);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.project.auth.application.auth.exception;
|
||||
|
||||
import com.project.auth.application.support.exception.AuthErrorCode;
|
||||
import com.project.auth.application.support.exception.BusinessException;
|
||||
|
||||
public class KeycloakAccountConflictException extends BusinessException {
|
||||
|
||||
public KeycloakAccountConflictException() {
|
||||
super(AuthErrorCode.KEYCLOAK_ACCOUNT_CONFLICT);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.project.auth.application.auth.exception;
|
||||
|
||||
import com.project.auth.application.support.exception.AuthErrorCode;
|
||||
import com.project.auth.application.support.exception.BusinessException;
|
||||
|
||||
public class KeycloakUserNotFoundException extends BusinessException {
|
||||
|
||||
public KeycloakUserNotFoundException() {
|
||||
super(AuthErrorCode.KEYCLOAK_USER_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.project.auth.application.auth.identity;
|
||||
|
||||
public record KeycloakUserClaims(
|
||||
String subject,
|
||||
String email,
|
||||
String name
|
||||
) {
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.project.auth.application.auth.identity;
|
||||
|
||||
public interface LoadKeycloakUserUseCase {
|
||||
|
||||
LoadedKeycloakUser load(KeycloakUserClaims claims);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.project.auth.application.auth.identity;
|
||||
|
||||
import com.project.auth.domain.user.model.User;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record LoadedKeycloakUser(
|
||||
UUID userId,
|
||||
String email,
|
||||
String name,
|
||||
String provider,
|
||||
String providerSubject
|
||||
) {
|
||||
|
||||
public static LoadedKeycloakUser from(User user) {
|
||||
return new LoadedKeycloakUser(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getName(),
|
||||
user.getProvider().name(),
|
||||
user.getProviderSubject()
|
||||
);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.project.auth.application.auth.identity.internal;
|
||||
|
||||
import com.project.auth.application.auth.exception.InvalidKeycloakClaimsException;
|
||||
import com.project.auth.application.auth.identity.KeycloakUserClaims;
|
||||
import com.project.auth.domain.user.exception.DomainException;
|
||||
import com.project.auth.domain.user.model.UserEmail;
|
||||
import com.project.auth.domain.user.model.UserName;
|
||||
|
||||
final class KeycloakUserClaimsValidator {
|
||||
|
||||
private KeycloakUserClaimsValidator() {
|
||||
}
|
||||
|
||||
static ValidatedKeycloakUserClaims validate(KeycloakUserClaims claims) {
|
||||
if (claims == null
|
||||
|| isBlank(claims.subject())
|
||||
|| isBlank(claims.email())
|
||||
|| isBlank(claims.name())) {
|
||||
throw new InvalidKeycloakClaimsException();
|
||||
}
|
||||
|
||||
try {
|
||||
return new ValidatedKeycloakUserClaims(
|
||||
claims.subject().trim(),
|
||||
UserEmail.from(claims.email()),
|
||||
UserName.from(claims.name())
|
||||
);
|
||||
} catch (DomainException exception) {
|
||||
throw new InvalidKeycloakClaimsException();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.project.auth.application.auth.identity.internal;
|
||||
|
||||
import com.project.auth.application.auth.exception.KeycloakAccountConflictException;
|
||||
import com.project.auth.application.auth.identity.KeycloakUserClaims;
|
||||
import com.project.auth.application.auth.identity.LoadKeycloakUserUseCase;
|
||||
import com.project.auth.application.auth.identity.LoadedKeycloakUser;
|
||||
import com.project.auth.application.auth.identity.port.out.LoadKeycloakUserPort;
|
||||
import com.project.auth.application.auth.identity.port.out.RegisterKeycloakUserPort;
|
||||
import com.project.auth.application.support.audit.AuthAuditEvent;
|
||||
import com.project.auth.application.support.audit.AuthAuditEventPublisher;
|
||||
import com.project.auth.application.support.audit.AuthAuditEventType;
|
||||
import com.project.auth.application.support.audit.AuthAuditFields;
|
||||
import com.project.auth.domain.user.model.AuthProvider;
|
||||
import com.project.auth.domain.user.model.User;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public class KeycloakUserLoader implements LoadKeycloakUserUseCase {
|
||||
|
||||
private final LoadKeycloakUserPort loadKeycloakUserPort;
|
||||
private final RegisterKeycloakUserPort registerKeycloakUserPort;
|
||||
private final AuthAuditEventPublisher authAuditEventPublisher;
|
||||
private final Clock clock;
|
||||
|
||||
public KeycloakUserLoader(
|
||||
LoadKeycloakUserPort loadKeycloakUserPort,
|
||||
RegisterKeycloakUserPort registerKeycloakUserPort,
|
||||
AuthAuditEventPublisher authAuditEventPublisher,
|
||||
Clock clock
|
||||
) {
|
||||
this.loadKeycloakUserPort = Objects.requireNonNull(loadKeycloakUserPort, "loadKeycloakUserPort must not be null");
|
||||
this.registerKeycloakUserPort = Objects.requireNonNull(
|
||||
registerKeycloakUserPort,
|
||||
"registerKeycloakUserPort must not be null"
|
||||
);
|
||||
this.authAuditEventPublisher = Objects.requireNonNull(
|
||||
authAuditEventPublisher,
|
||||
"authAuditEventPublisher must not be null"
|
||||
);
|
||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public LoadedKeycloakUser load(KeycloakUserClaims claims) {
|
||||
ValidatedKeycloakUserClaims validatedClaims = KeycloakUserClaimsValidator.validate(claims);
|
||||
|
||||
User user = loadKeycloakUserPort.findByProviderAndProviderSubject(AuthProvider.KEYCLOAK, validatedClaims.subject())
|
||||
.orElseGet(() -> autoRegister(validatedClaims));
|
||||
|
||||
return LoadedKeycloakUser.from(user);
|
||||
}
|
||||
|
||||
private User autoRegister(ValidatedKeycloakUserClaims claims) {
|
||||
if (loadKeycloakUserPort.existsByEmail(claims.email())) {
|
||||
throw new KeycloakAccountConflictException();
|
||||
}
|
||||
|
||||
User newUser = User.registerKeycloak(
|
||||
UUID.randomUUID(),
|
||||
claims.email(),
|
||||
claims.name(),
|
||||
claims.subject(),
|
||||
Instant.now(clock)
|
||||
);
|
||||
User saved = registerKeycloakUserPort.register(newUser);
|
||||
authAuditEventPublisher.publish(AuthAuditEvent.info(
|
||||
AuthAuditEventType.KEYCLOAK_USER_AUTO_REGISTERED.code(),
|
||||
AuthAuditFields.PROVIDER, AuthProvider.KEYCLOAK,
|
||||
AuthAuditFields.SUBJECT, claims.subject()
|
||||
));
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.project.auth.application.auth.identity.internal;
|
||||
|
||||
import com.project.auth.domain.user.model.UserEmail;
|
||||
import com.project.auth.domain.user.model.UserName;
|
||||
|
||||
record ValidatedKeycloakUserClaims(
|
||||
String subject,
|
||||
UserEmail email,
|
||||
UserName name
|
||||
) {
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.project.auth.application.auth.identity.port.out;
|
||||
|
||||
import com.project.auth.domain.user.model.AuthProvider;
|
||||
import com.project.auth.domain.user.model.User;
|
||||
import com.project.auth.domain.user.model.UserEmail;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface LoadKeycloakUserPort {
|
||||
|
||||
Optional<User> findByProviderAndProviderSubject(AuthProvider provider, String providerSubject);
|
||||
|
||||
boolean existsByEmail(UserEmail email);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.project.auth.application.auth.identity.port.out;
|
||||
|
||||
import com.project.auth.domain.user.model.User;
|
||||
|
||||
public interface RegisterKeycloakUserPort {
|
||||
|
||||
User register(User user);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.project.auth.application.support.audit;
|
||||
|
||||
public enum AuditLevel {
|
||||
INFO,
|
||||
WARN
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.project.auth.application.support.audit;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public record AuthAuditEvent(AuditLevel level, String eventType, Map<String, String> fields) {
|
||||
|
||||
public AuthAuditEvent {
|
||||
Objects.requireNonNull(level, "level must not be null");
|
||||
Objects.requireNonNull(eventType, "eventType must not be null");
|
||||
Objects.requireNonNull(fields, "fields must not be null");
|
||||
fields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
|
||||
}
|
||||
|
||||
public static AuthAuditEvent info(String eventType, Object... keyValues) {
|
||||
return new AuthAuditEvent(AuditLevel.INFO, eventType, toFields(keyValues));
|
||||
}
|
||||
|
||||
public static AuthAuditEvent warn(String eventType, Object... keyValues) {
|
||||
return new AuthAuditEvent(AuditLevel.WARN, eventType, toFields(keyValues));
|
||||
}
|
||||
|
||||
private static Map<String, String> toFields(Object... keyValues) {
|
||||
if (keyValues.length % 2 != 0) {
|
||||
throw new IllegalArgumentException("keyValues must contain an even number of elements");
|
||||
}
|
||||
|
||||
LinkedHashMap<String, String> fields = new LinkedHashMap<>();
|
||||
for (int index = 0; index < keyValues.length; index += 2) {
|
||||
Object key = Objects.requireNonNull(keyValues[index], "field key must not be null");
|
||||
Object value = Objects.requireNonNull(keyValues[index + 1], "field value must not be null");
|
||||
fields.put(key.toString(), value.toString());
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.project.auth.application.support.audit;
|
||||
|
||||
public interface AuthAuditEventPublisher {
|
||||
|
||||
void publish(AuthAuditEvent event);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.project.auth.application.support.audit;
|
||||
|
||||
public enum AuthAuditEventType {
|
||||
KEYCLOAK_USER_NOT_FOUND("KEYCLOAK_USER_NOT_FOUND"),
|
||||
KEYCLOAK_USER_AUTO_REGISTERED("KEYCLOAK_USER_AUTO_REGISTERED"),
|
||||
AUTHENTICATION_REQUIRED("AUTHENTICATION_REQUIRED"),
|
||||
ACCESS_DENIED("ACCESS_DENIED");
|
||||
|
||||
private final String code;
|
||||
|
||||
AuthAuditEventType(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.project.auth.application.support.audit;
|
||||
|
||||
public enum AuthAuditFailureReason {
|
||||
EMAIL_CONFLICT("email_conflict"),
|
||||
DUPLICATE_EMAIL_RACE_CONDITION("duplicate_email_race_condition");
|
||||
|
||||
private final String code;
|
||||
|
||||
AuthAuditFailureReason(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.project.auth.application.support.audit;
|
||||
|
||||
import com.project.auth.domain.user.model.UserEmail;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class AuthAuditFields {
|
||||
|
||||
public static final String EVENT_TYPE = "eventType";
|
||||
public static final String ACTOR_ID = "actorId";
|
||||
public static final String USER_ID_HASH = "userIdHash";
|
||||
public static final String EMAIL_MASKED = "emailMasked";
|
||||
public static final String PROVIDER = "provider";
|
||||
public static final String REASON = "reason";
|
||||
public static final String METHOD = "method";
|
||||
public static final String REQUEST_PATH = "requestPath";
|
||||
public static final String KEY_ID = "keyId";
|
||||
public static final String EXPIRES_IN_SECONDS = "expiresInSeconds";
|
||||
public static final String SUBJECT = "subject";
|
||||
|
||||
private static final String HASH_PREFIX = "sha256:";
|
||||
private static final int HASH_HEX_LENGTH = 16;
|
||||
private static final HexFormat HEX_FORMAT = HexFormat.of();
|
||||
|
||||
private AuthAuditFields() {
|
||||
}
|
||||
|
||||
public static String maskedEmail(UserEmail email) {
|
||||
Objects.requireNonNull(email, "email must not be null");
|
||||
return maskEmail(email.value());
|
||||
}
|
||||
|
||||
public static String userIdHash(UUID userId) {
|
||||
Objects.requireNonNull(userId, "userId must not be null");
|
||||
return HASH_PREFIX + sha256Hex(userId.toString()).substring(0, HASH_HEX_LENGTH);
|
||||
}
|
||||
|
||||
private static String maskEmail(String email) {
|
||||
int atIndex = email.indexOf('@');
|
||||
if (atIndex <= 0 || atIndex == email.length() - 1) {
|
||||
return "***";
|
||||
}
|
||||
|
||||
String localPart = email.substring(0, atIndex);
|
||||
String domain = email.substring(atIndex + 1);
|
||||
return localPartPrefix(localPart) + "***@" + domain;
|
||||
}
|
||||
|
||||
private static String localPartPrefix(String localPart) {
|
||||
if (localPart.length() == 1) {
|
||||
return localPart;
|
||||
}
|
||||
return localPart.substring(0, Math.min(localPart.length(), 2));
|
||||
}
|
||||
|
||||
private static String sha256Hex(String value) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HEX_FORMAT.formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 digest is not available.", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.project.auth.application.support.exception;
|
||||
|
||||
public enum AuthErrorCode implements ClientFacingErrorCode {
|
||||
AUTHENTICATION_REQUIRED("AUTH-001", "인증이 필요합니다."),
|
||||
ACCESS_DENIED("AUTH-002", "접근 권한이 없습니다."),
|
||||
KEYCLOAK_CLAIMS_INVALID("AUTH-003", "Keycloak 토큰 클레임이 올바르지 않습니다."),
|
||||
KEYCLOAK_USER_NOT_FOUND("AUTH-004", "연결된 내부 Keycloak 사용자를 찾을 수 없습니다."),
|
||||
KEYCLOAK_ACCOUNT_CONFLICT("AUTH-005", "Keycloak 계정과 연결할 수 없는 내부 사용자 정보입니다.");
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
AuthErrorCode(String code, String message) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.project.auth.application.support.exception;
|
||||
|
||||
public abstract class BusinessException extends RuntimeException {
|
||||
|
||||
private final ClientFacingErrorCode errorCode;
|
||||
|
||||
protected BusinessException(ClientFacingErrorCode errorCode) {
|
||||
super(errorCode.message());
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
protected BusinessException(ClientFacingErrorCode errorCode, String detailMessage) {
|
||||
super(detailMessage);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
protected BusinessException(ClientFacingErrorCode errorCode, Throwable cause) {
|
||||
super(errorCode.message(), cause);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
protected BusinessException(ClientFacingErrorCode errorCode, String detailMessage, Throwable cause) {
|
||||
super(detailMessage, cause);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public ClientFacingErrorCode getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.project.auth.application.support.exception;
|
||||
|
||||
/**
|
||||
* 클라이언트에 노출되는 ErrorCode 마커 인터페이스.
|
||||
*
|
||||
* ApiErrorHttpStatusMapper.map(...)의 시그니처를 이 타입으로 좁혀, 내부 전용인
|
||||
* ExternalErrorCode(InfrastructureErrorCode 등)이 클라이언트 응답 경로로 흘러들어가는
|
||||
* 것을 컴파일 단계에서 차단한다.
|
||||
*
|
||||
* TODO(i18n): 현재 message()는 한국어 하드코딩 문자열을 반환한다. MessageSource를 도입할 때는
|
||||
* 기존 message()를 기본 메시지 키로 취급하고, 실제 렌더링 문자열은 응답을 만들 때 요청 로케일에
|
||||
* 맞추어 MessageSource로 해석하도록 변경한다.
|
||||
*/
|
||||
public non-sealed interface ClientFacingErrorCode extends ErrorCode {
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.project.auth.application.support.exception;
|
||||
|
||||
public enum CommonErrorCode implements ClientFacingErrorCode {
|
||||
INTERNAL_SERVER_ERROR("COMMON-999", "예상하지 못한 오류가 발생했습니다.");
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
CommonErrorCode(String code, String message) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.project.auth.application.support.exception;
|
||||
|
||||
public sealed interface ErrorCode
|
||||
permits ClientFacingErrorCode, ExternalErrorCode {
|
||||
|
||||
String code();
|
||||
|
||||
String message();
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package com.project.auth.application.support.exception;
|
||||
|
||||
public non-sealed interface ExternalErrorCode extends ErrorCode {
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.project.auth.application.support.logging;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
public final class LogSanitizer {
|
||||
|
||||
private static final String UNAVAILABLE_VALUE = "-";
|
||||
private static final String ANONYMOUS_VALUE = "anonymous";
|
||||
private static final String HASH_PREFIX = "sha256:";
|
||||
private static final int HASH_HEX_LENGTH = 16;
|
||||
private static final int DEFAULT_MAX_LENGTH = 160;
|
||||
private static final int USER_AGENT_MAX_LENGTH = 120;
|
||||
private static final int REQUEST_PATH_MAX_LENGTH = 200;
|
||||
private static final HexFormat HEX_FORMAT = HexFormat.of();
|
||||
|
||||
private LogSanitizer() {
|
||||
}
|
||||
|
||||
public static String userAgent(String value) {
|
||||
return normalize(value, USER_AGENT_MAX_LENGTH);
|
||||
}
|
||||
|
||||
public static String clientIp(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return UNAVAILABLE_VALUE;
|
||||
}
|
||||
String normalized = normalize(value, DEFAULT_MAX_LENGTH);
|
||||
String[] octets = normalized.split("\\.", -1);
|
||||
if (octets.length == 4 && isIpv4Octets(octets)) {
|
||||
return octets[0] + "." + octets[1] + "." + octets[2] + ".0";
|
||||
}
|
||||
return HASH_PREFIX + sha256Hex(normalized).substring(0, HASH_HEX_LENGTH);
|
||||
}
|
||||
|
||||
public static String requestPath(String value) {
|
||||
return normalize(value, REQUEST_PATH_MAX_LENGTH);
|
||||
}
|
||||
|
||||
public static String actorId(String value) {
|
||||
if (value == null || value.isBlank() || ANONYMOUS_VALUE.equals(value)) {
|
||||
return ANONYMOUS_VALUE;
|
||||
}
|
||||
String normalized = normalize(value, DEFAULT_MAX_LENGTH);
|
||||
if (normalized.contains("@")) {
|
||||
return maskEmail(normalized);
|
||||
}
|
||||
return HASH_PREFIX + sha256Hex(normalized).substring(0, HASH_HEX_LENGTH);
|
||||
}
|
||||
|
||||
public static String normalize(String value) {
|
||||
return normalize(value, DEFAULT_MAX_LENGTH);
|
||||
}
|
||||
|
||||
private static String normalize(String value, int maxLength) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return UNAVAILABLE_VALUE;
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder(Math.min(value.length(), maxLength));
|
||||
for (int index = 0; index < value.length() && builder.length() < maxLength; index++) {
|
||||
char character = value.charAt(index);
|
||||
if (Character.isISOControl(character) || Character.isWhitespace(character) || character == '='
|
||||
|| character == '|') {
|
||||
builder.append('_');
|
||||
} else {
|
||||
builder.append(character);
|
||||
}
|
||||
}
|
||||
if (value.length() > maxLength) {
|
||||
builder.append("...");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static String maskEmail(String email) {
|
||||
int atIndex = email.indexOf('@');
|
||||
if (atIndex <= 0 || atIndex == email.length() - 1) {
|
||||
return "***";
|
||||
}
|
||||
|
||||
String localPart = email.substring(0, atIndex);
|
||||
String domain = email.substring(atIndex + 1);
|
||||
return localPartPrefix(localPart) + "***@" + domain;
|
||||
}
|
||||
|
||||
private static String localPartPrefix(String localPart) {
|
||||
if (localPart.length() == 1) {
|
||||
return localPart;
|
||||
}
|
||||
return localPart.substring(0, Math.min(localPart.length(), 2));
|
||||
}
|
||||
|
||||
private static boolean isIpv4Octets(String[] octets) {
|
||||
for (String octet : octets) {
|
||||
if (!isIpv4Octet(octet)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isIpv4Octet(String octet) {
|
||||
if (octet.isEmpty() || octet.length() > 3) {
|
||||
return false;
|
||||
}
|
||||
int value = 0;
|
||||
for (int index = 0; index < octet.length(); index++) {
|
||||
char character = octet.charAt(index);
|
||||
if (!Character.isDigit(character)) {
|
||||
return false;
|
||||
}
|
||||
value = value * 10 + Character.digit(character, 10);
|
||||
}
|
||||
return value <= 255;
|
||||
}
|
||||
|
||||
private static String sha256Hex(String value) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HEX_FORMAT.formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 digest is not available.", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.project.auth.application.auth.identity.internal;
|
||||
|
||||
import com.project.auth.application.auth.exception.InvalidKeycloakClaimsException;
|
||||
import com.project.auth.application.auth.exception.KeycloakAccountConflictException;
|
||||
import com.project.auth.application.auth.identity.KeycloakUserClaims;
|
||||
import com.project.auth.application.auth.identity.LoadedKeycloakUser;
|
||||
import com.project.auth.application.auth.identity.port.out.LoadKeycloakUserPort;
|
||||
import com.project.auth.application.auth.identity.port.out.RegisterKeycloakUserPort;
|
||||
import com.project.auth.application.support.audit.AuthAuditEvent;
|
||||
import com.project.auth.application.support.audit.AuthAuditEventPublisher;
|
||||
import com.project.auth.domain.user.model.AuthProvider;
|
||||
import com.project.auth.domain.user.model.User;
|
||||
import com.project.auth.domain.user.model.UserEmail;
|
||||
import com.project.auth.domain.user.model.UserName;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class KeycloakUserLoaderTest {
|
||||
|
||||
private static final Instant FIXED_NOW = Instant.parse("2026-04-17T00:00:00Z");
|
||||
|
||||
private FakeKeycloakUserRepository userRepository;
|
||||
private RecordingAuthAuditEventPublisher auditEventPublisher;
|
||||
private KeycloakUserLoader loader;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository = new FakeKeycloakUserRepository();
|
||||
auditEventPublisher = new RecordingAuthAuditEventPublisher();
|
||||
loader = new KeycloakUserLoader(
|
||||
userRepository,
|
||||
userRepository,
|
||||
auditEventPublisher,
|
||||
Clock.fixed(FIXED_NOW, ZoneOffset.UTC)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadReturnsExistingUserByKeycloakSubject() {
|
||||
userRepository.usersBySubject.put("keycloak-subject-1", User.registerKeycloak(
|
||||
UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||
UserEmail.from("tester@example.com"),
|
||||
UserName.from("테스터"),
|
||||
"keycloak-subject-1",
|
||||
Instant.parse("2026-04-17T00:00:00Z")
|
||||
));
|
||||
|
||||
LoadedKeycloakUser user = loader.load(new KeycloakUserClaims(
|
||||
"keycloak-subject-1",
|
||||
"changed@example.com",
|
||||
"변경"
|
||||
));
|
||||
|
||||
assertThat(user.userId()).isEqualTo(UUID.fromString("11111111-1111-1111-1111-111111111111"));
|
||||
assertThat(user.email()).isEqualTo("tester@example.com");
|
||||
assertThat(user.name()).isEqualTo("테스터");
|
||||
assertThat(user.provider()).isEqualTo(AuthProvider.KEYCLOAK.name());
|
||||
assertThat(user.providerSubject()).isEqualTo("keycloak-subject-1");
|
||||
assertThat(auditEventPublisher.events).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadAutoRegistersMissingInternalUser() {
|
||||
LoadedKeycloakUser user = loader.load(new KeycloakUserClaims(
|
||||
"keycloak-subject-1",
|
||||
"tester@example.com",
|
||||
"테스터"
|
||||
));
|
||||
|
||||
assertThat(user.userId()).isNotNull();
|
||||
assertThat(user.email()).isEqualTo("tester@example.com");
|
||||
assertThat(user.name()).isEqualTo("테스터");
|
||||
assertThat(user.provider()).isEqualTo(AuthProvider.KEYCLOAK.name());
|
||||
assertThat(user.providerSubject()).isEqualTo("keycloak-subject-1");
|
||||
assertThat(userRepository.usersBySubject.get("keycloak-subject-1").getCreatedAt()).isEqualTo(FIXED_NOW);
|
||||
assertThat(auditEventPublisher.events)
|
||||
.singleElement()
|
||||
.satisfies(event -> {
|
||||
assertThat(event.eventType()).isEqualTo("KEYCLOAK_USER_AUTO_REGISTERED");
|
||||
assertThat(event.fields()).containsEntry("subject", "keycloak-subject-1");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadRejectsBlankClaimsAsInvalid() {
|
||||
assertThatThrownBy(() -> loader.load(new KeycloakUserClaims(
|
||||
"keycloak-subject-1",
|
||||
"",
|
||||
"테스터"
|
||||
))).isInstanceOf(InvalidKeycloakClaimsException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadRejectsMalformedEmailClaimAsInvalid() {
|
||||
assertThatThrownBy(() -> loader.load(new KeycloakUserClaims(
|
||||
"keycloak-subject-1",
|
||||
"not-an-email",
|
||||
"테스터"
|
||||
))).isInstanceOf(InvalidKeycloakClaimsException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadRejectsMissingSubjectWhenEmailAlreadyBelongsToAnotherUser() {
|
||||
userRepository.usersBySubject.put("other-subject", User.registerKeycloak(
|
||||
UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||
UserEmail.from("tester@example.com"),
|
||||
UserName.from("테스터"),
|
||||
"other-subject",
|
||||
FIXED_NOW
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> loader.load(new KeycloakUserClaims(
|
||||
"keycloak-subject-1",
|
||||
"tester@example.com",
|
||||
"테스터"
|
||||
))).isInstanceOf(KeycloakAccountConflictException.class);
|
||||
}
|
||||
|
||||
private static final class FakeKeycloakUserRepository implements LoadKeycloakUserPort, RegisterKeycloakUserPort {
|
||||
|
||||
private final Map<String, User> usersBySubject = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Optional<User> findByProviderAndProviderSubject(AuthProvider provider, String providerSubject) {
|
||||
if (provider != AuthProvider.KEYCLOAK) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(usersBySubject.get(providerSubject));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByEmail(UserEmail email) {
|
||||
return usersBySubject.values().stream()
|
||||
.anyMatch(user -> user.getEmail().equals(email.value()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public User register(User user) {
|
||||
usersBySubject.put(user.getProviderSubject(), user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingAuthAuditEventPublisher implements AuthAuditEventPublisher {
|
||||
|
||||
private final List<AuthAuditEvent> events = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void publish(AuthAuditEvent event) {
|
||||
events.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.project.auth.application.support.exception;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* BusinessException 4개 protected 생성자 + getErrorCode 단위 테스트.
|
||||
* 다른 모듈(presentation 핸들러)에서 BusinessException을 instantiate해도 모듈 격리 측정에서는
|
||||
* application 모듈 라인이 카운트되지 않으므로 본 테스트로 직접 호출.
|
||||
*/
|
||||
class BusinessExceptionTest {
|
||||
|
||||
@Test
|
||||
void single_arg_constructor_uses_error_code_message() {
|
||||
BusinessException ex = new TestException(AuthErrorCode.AUTHENTICATION_REQUIRED);
|
||||
|
||||
assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.AUTHENTICATION_REQUIRED);
|
||||
assertThat(ex.getMessage()).isEqualTo(AuthErrorCode.AUTHENTICATION_REQUIRED.message());
|
||||
assertThat(ex.getCause()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void detail_message_constructor_overrides_message() {
|
||||
BusinessException ex = new TestException(AuthErrorCode.ACCESS_DENIED, "specific detail");
|
||||
|
||||
assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.ACCESS_DENIED);
|
||||
assertThat(ex.getMessage()).isEqualTo("specific detail");
|
||||
assertThat(ex.getCause()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cause_constructor_preserves_cause_and_uses_error_code_message() {
|
||||
Throwable cause = new IllegalStateException("inner");
|
||||
BusinessException ex = new TestException(AuthErrorCode.KEYCLOAK_USER_NOT_FOUND, cause);
|
||||
|
||||
assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.KEYCLOAK_USER_NOT_FOUND);
|
||||
assertThat(ex.getMessage()).isEqualTo(AuthErrorCode.KEYCLOAK_USER_NOT_FOUND.message());
|
||||
assertThat(ex.getCause()).isSameAs(cause);
|
||||
}
|
||||
|
||||
@Test
|
||||
void detail_message_and_cause_constructor_preserves_both() {
|
||||
Throwable cause = new IllegalStateException("inner");
|
||||
BusinessException ex = new TestException(AuthErrorCode.KEYCLOAK_CLAIMS_INVALID, "detail", cause);
|
||||
|
||||
assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.KEYCLOAK_CLAIMS_INVALID);
|
||||
assertThat(ex.getMessage()).isEqualTo("detail");
|
||||
assertThat(ex.getCause()).isSameAs(cause);
|
||||
}
|
||||
|
||||
private static final class TestException extends BusinessException {
|
||||
TestException(ClientFacingErrorCode errorCode) {
|
||||
super(errorCode);
|
||||
}
|
||||
TestException(ClientFacingErrorCode errorCode, String detailMessage) {
|
||||
super(errorCode, detailMessage);
|
||||
}
|
||||
TestException(ClientFacingErrorCode errorCode, Throwable cause) {
|
||||
super(errorCode, cause);
|
||||
}
|
||||
TestException(ClientFacingErrorCode errorCode, String detailMessage, Throwable cause) {
|
||||
super(errorCode, detailMessage, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.project.auth.application.support.exception;
|
||||
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tier 1 ErrorCode enum 인스턴스의 code()/message()/values() 호출 라인을 application 모듈
|
||||
* 단위 측정에서 카운트되도록 한다. 통상적으로는 다른 모듈이 호출하지만 모듈 격리 측정에서는
|
||||
* 그 호출이 application 모듈 .exec에 기록되지 않는다.
|
||||
*/
|
||||
class ErrorCodeEnumTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(CommonErrorCode.class)
|
||||
void common_error_code_exposes_code_and_message(CommonErrorCode code) {
|
||||
assertThat(code.code()).isNotBlank().startsWith("COMMON-");
|
||||
assertThat(code.message()).isNotBlank();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(AuthErrorCode.class)
|
||||
void auth_error_code_exposes_code_and_message(AuthErrorCode code) {
|
||||
assertThat(code.code()).isNotBlank().startsWith("AUTH-");
|
||||
assertThat(code.message()).isNotBlank();
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.project.auth.application.support.logging;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* jqwik 속성 테스트가 도달하기 어려운 LogSanitizer의 edge case를 직접 호출.
|
||||
*
|
||||
* 환경 의존(SHA-256 미지원) 분기는 단위 테스트로도 트리거 불가하므로 미커버 인정.
|
||||
*/
|
||||
class LogSanitizerEdgeCaseTest {
|
||||
|
||||
@Test
|
||||
void normalize_single_arg_uses_default_max_length() {
|
||||
// public single-arg normalize(value)는 normalize(value, DEFAULT_MAX_LENGTH)에 위임
|
||||
assertThat(LogSanitizer.normalize("hello")).isEqualTo("hello");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"@example.com", "user@", "@"})
|
||||
void mask_email_returns_triple_star_for_malformed_emails(String malformed) {
|
||||
// atIndex <= 0 또는 atIndex == length-1 → "***"
|
||||
assertThat(LogSanitizer.actorId(malformed)).isEqualTo("***");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"1.2.3.999", // value > 255
|
||||
"1.2.3.1234", // octet.length() > 3
|
||||
"1.2.3.abc" // !Character.isDigit
|
||||
})
|
||||
void client_ip_falls_back_to_hash_when_octet_invalid(String input) {
|
||||
String result = LogSanitizer.clientIp(input);
|
||||
assertThat(result).startsWith("sha256:");
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package com.project.auth.application.support.logging;
|
||||
|
||||
import net.jqwik.api.Arbitraries;
|
||||
import net.jqwik.api.Arbitrary;
|
||||
import net.jqwik.api.ForAll;
|
||||
import net.jqwik.api.From;
|
||||
import net.jqwik.api.Property;
|
||||
import net.jqwik.api.Provide;
|
||||
import net.jqwik.api.constraints.StringLength;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* LogSanitizer 속성 기반 테스트.
|
||||
*
|
||||
* 검증 대상 속성:
|
||||
* - normalize: null/blank → "-", 결과 길이 한도 보장(단, "..." 접미사 허용),
|
||||
* 제어/공백/'='/'|' 문자 제거.
|
||||
* - clientIp: IPv4 형식이면 마지막 옥텟이 0으로 마스킹, 그 외는 sha256 prefix.
|
||||
* - actorId: 이메일이면 마스킹, 그 외 anonymous/blank/null은 "anonymous", 나머지는 sha256 prefix.
|
||||
* - requestPath: 일반 normalize와 동일 정책 적용 + 더 큰 길이 한도(200).
|
||||
*/
|
||||
class LogSanitizerPropertyTest {
|
||||
|
||||
private static final int DEFAULT_MAX = 160;
|
||||
private static final int REQUEST_PATH_MAX = 200;
|
||||
private static final int USER_AGENT_MAX = 120;
|
||||
|
||||
@Property
|
||||
void normalize_null_or_blank_returns_dash(@ForAll @From("blankOrNull") String input) {
|
||||
assertThat(LogSanitizer.normalize(input)).isEqualTo("-");
|
||||
}
|
||||
|
||||
@Property
|
||||
void normalize_strips_control_whitespace_equals_pipe_chars(@ForAll @StringLength(min = 1, max = 300) String raw) {
|
||||
String result = LogSanitizer.normalize(raw);
|
||||
if (result.equals("-")) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < result.length(); i++) {
|
||||
char ch = result.charAt(i);
|
||||
// "..." 접미사 자체는 정규화되지 않는 평문이므로 통과
|
||||
if (ch == '.') continue;
|
||||
assertThat(Character.isISOControl(ch))
|
||||
.as("ISO control char leaked at index %d", i)
|
||||
.isFalse();
|
||||
assertThat(ch == '=' || ch == '|')
|
||||
.as("forbidden delimiter '=' or '|' leaked at index %d", i)
|
||||
.isFalse();
|
||||
assertThat(Character.isWhitespace(ch) && ch != '_')
|
||||
.as("whitespace leaked at index %d", i)
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Property
|
||||
void normalize_output_length_is_bounded_by_max_plus_three(@ForAll @StringLength(min = 0, max = 1000) String raw) {
|
||||
String result = LogSanitizer.normalize(raw);
|
||||
if (result.equals("-")) {
|
||||
return;
|
||||
}
|
||||
// sanitizer는 maxLength까지의 문자 + 입력이 maxLength 초과 시 "..." 접미사를 붙인다
|
||||
assertThat(result.length()).isLessThanOrEqualTo(DEFAULT_MAX + 3);
|
||||
}
|
||||
|
||||
@Property
|
||||
void requestPath_output_length_is_bounded(@ForAll @StringLength(min = 0, max = 1000) String raw) {
|
||||
String result = LogSanitizer.requestPath(raw);
|
||||
if (result.equals("-")) {
|
||||
return;
|
||||
}
|
||||
assertThat(result.length()).isLessThanOrEqualTo(REQUEST_PATH_MAX + 3);
|
||||
}
|
||||
|
||||
@Property
|
||||
void userAgent_output_length_is_bounded(@ForAll @StringLength(min = 0, max = 1000) String raw) {
|
||||
String result = LogSanitizer.userAgent(raw);
|
||||
if (result.equals("-")) {
|
||||
return;
|
||||
}
|
||||
assertThat(result.length()).isLessThanOrEqualTo(USER_AGENT_MAX + 3);
|
||||
}
|
||||
|
||||
@Property
|
||||
void clientIp_with_valid_ipv4_masks_last_octet(@ForAll("ipv4") String ipv4) {
|
||||
String result = LogSanitizer.clientIp(ipv4);
|
||||
assertThat(result).endsWith(".0");
|
||||
assertThat(result.split("\\.")).hasSize(4);
|
||||
}
|
||||
|
||||
@Property
|
||||
void clientIp_with_non_ipv4_falls_back_to_hash(@ForAll @StringLength(min = 1, max = 200) String value) {
|
||||
// IPv4 형식이면 그대로 하위 검증을 통과하므로 이 속성에서는 제외
|
||||
if (looksLikeIpv4(value)) return;
|
||||
String result = LogSanitizer.clientIp(value);
|
||||
if ("-".equals(result)) {
|
||||
assertThat(value.trim()).isEmpty();
|
||||
return;
|
||||
}
|
||||
assertThat(result).startsWith("sha256:");
|
||||
// sha256 hex prefix는 16자
|
||||
assertThat(result.length()).isEqualTo("sha256:".length() + 16);
|
||||
}
|
||||
|
||||
@Property
|
||||
void clientIp_for_blank_returns_dash(@ForAll @From("blankOrNull") String input) {
|
||||
assertThat(LogSanitizer.clientIp(input)).isEqualTo("-");
|
||||
}
|
||||
|
||||
@Property
|
||||
void actorId_for_blank_anonymous_returns_anonymous(@ForAll @From("blankOrNullOrAnonymous") String input) {
|
||||
assertThat(LogSanitizer.actorId(input)).isEqualTo("anonymous");
|
||||
}
|
||||
|
||||
@Property
|
||||
void actorId_for_email_masks_local_part(@ForAll("email") String email) {
|
||||
String result = LogSanitizer.actorId(email);
|
||||
assertThat(result).contains("@");
|
||||
// local prefix는 최대 2글자 + ***
|
||||
int atIndex = result.indexOf('@');
|
||||
String localResult = result.substring(0, atIndex);
|
||||
assertThat(localResult).endsWith("***");
|
||||
// 마스킹 결과 길이는 prefix(1~2) + "***" = 4~5
|
||||
assertThat(localResult.length()).isBetween(4, 5);
|
||||
}
|
||||
|
||||
@Property
|
||||
void actorId_for_non_email_non_blank_returns_hash_prefix(
|
||||
@ForAll @StringLength(min = 1, max = 100) String value
|
||||
) {
|
||||
if (value.isBlank() || "anonymous".equals(value) || value.contains("@")) return;
|
||||
String result = LogSanitizer.actorId(value);
|
||||
assertThat(result).startsWith("sha256:");
|
||||
assertThat(result.length()).isEqualTo("sha256:".length() + 16);
|
||||
}
|
||||
|
||||
// ---------- Arbitraries ----------
|
||||
|
||||
@Provide
|
||||
Arbitrary<String> blankOrNull() {
|
||||
return Arbitraries.of("", " ", " ", "\t", "\n", null);
|
||||
}
|
||||
|
||||
@Provide
|
||||
Arbitrary<String> blankOrNullOrAnonymous() {
|
||||
return Arbitraries.of("", " ", "anonymous", null);
|
||||
}
|
||||
|
||||
@Provide
|
||||
Arbitrary<String> ipv4() {
|
||||
Arbitrary<Integer> octet = Arbitraries.integers().between(0, 255);
|
||||
return octet.flatMap(a ->
|
||||
octet.flatMap(b ->
|
||||
octet.flatMap(c ->
|
||||
octet.map(d -> a + "." + b + "." + c + "." + d))));
|
||||
}
|
||||
|
||||
@Provide
|
||||
Arbitrary<String> email() {
|
||||
Arbitrary<String> local = Arbitraries.strings()
|
||||
.alpha().ofMinLength(1).ofMaxLength(20);
|
||||
Arbitrary<String> domain = Arbitraries.strings()
|
||||
.alpha().ofMinLength(2).ofMaxLength(20);
|
||||
return local.flatMap(l -> domain.map(d -> l + "@" + d + ".com"));
|
||||
}
|
||||
|
||||
private static boolean looksLikeIpv4(String value) {
|
||||
String[] parts = value.split("\\.", -1);
|
||||
if (parts.length != 4) return false;
|
||||
for (String p : parts) {
|
||||
if (p.isEmpty() || p.length() > 3) return false;
|
||||
for (int i = 0; i < p.length(); i++) {
|
||||
if (!Character.isDigit(p.charAt(i))) return false;
|
||||
}
|
||||
int n = Integer.parseInt(p);
|
||||
if (n > 255) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user