test(04-01): add failing tests for security services

- JwtService: 7 tests for access/refresh token creation and validation
- Ed25519SigningService: 5 tests for keypair, signing, verification
- BootstrapTokenValidator: 6 tests for token matching and rotation
- Core interfaces and stub implementations (all throw UnsupportedOperationException)
- Added nimbus-jose-jwt and spring-boot-starter-security dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 19:58:59 +01:00
parent b7c35037e6
commit 51a02700dd
11 changed files with 482 additions and 0 deletions

View File

@@ -66,11 +66,25 @@
<artifactId>org.eclipse.xtext.xbase.lib</artifactId> <artifactId>org.eclipse.xtext.xbase.lib</artifactId>
<version>2.37.0</version> <version>2.37.0</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.47</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.testcontainers</groupId> <groupId>org.testcontainers</groupId>
<artifactId>testcontainers-clickhouse</artifactId> <artifactId>testcontainers-clickhouse</artifactId>

View File

@@ -0,0 +1,24 @@
package com.cameleer3.server.app.security;
/**
* Validates bootstrap tokens used for initial agent registration.
* Stub — to be implemented in GREEN phase.
*/
public class BootstrapTokenValidator {
private final SecurityProperties properties;
public BootstrapTokenValidator(SecurityProperties properties) {
this.properties = properties;
}
/**
* Validates the provided token against the configured bootstrap token(s).
*
* @param provided the token to validate
* @return true if the token matches the current or previous bootstrap token
*/
public boolean validate(String provided) {
throw new UnsupportedOperationException("Not yet implemented");
}
}

View File

@@ -0,0 +1,24 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.core.security.Ed25519SigningService;
/**
* JDK 17 Ed25519 signing implementation.
* Stub — to be implemented in GREEN phase.
*/
public class Ed25519SigningServiceImpl implements Ed25519SigningService {
public Ed25519SigningServiceImpl() {
// KeyPair generation will go here
}
@Override
public String sign(String payload) {
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public String getPublicKeyBase64() {
throw new UnsupportedOperationException("Not yet implemented");
}
}

View File

@@ -0,0 +1,37 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.core.security.InvalidTokenException;
import com.cameleer3.server.core.security.JwtService;
/**
* HMAC-SHA256 JWT implementation using Nimbus JOSE+JWT.
* Stub — to be implemented in GREEN phase.
*/
public class JwtServiceImpl implements JwtService {
private final SecurityProperties properties;
public JwtServiceImpl(SecurityProperties properties) {
this.properties = properties;
}
@Override
public String createAccessToken(String agentId, String group) {
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public String createRefreshToken(String agentId, String group) {
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public String validateAndExtractAgentId(String token) {
throw new UnsupportedOperationException("Not yet implemented");
}
@Override
public String validateRefreshToken(String token) {
throw new UnsupportedOperationException("Not yet implemented");
}
}

View File

@@ -0,0 +1,48 @@
package com.cameleer3.server.app.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Configuration properties for security settings.
* Bound from the {@code security.*} namespace in application.yml.
*/
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
private long accessTokenExpiryMs = 3_600_000;
private long refreshTokenExpiryMs = 604_800_000;
private String bootstrapToken;
private String bootstrapTokenPrevious;
public long getAccessTokenExpiryMs() {
return accessTokenExpiryMs;
}
public void setAccessTokenExpiryMs(long accessTokenExpiryMs) {
this.accessTokenExpiryMs = accessTokenExpiryMs;
}
public long getRefreshTokenExpiryMs() {
return refreshTokenExpiryMs;
}
public void setRefreshTokenExpiryMs(long refreshTokenExpiryMs) {
this.refreshTokenExpiryMs = refreshTokenExpiryMs;
}
public String getBootstrapToken() {
return bootstrapToken;
}
public void setBootstrapToken(String bootstrapToken) {
this.bootstrapToken = bootstrapToken;
}
public String getBootstrapTokenPrevious() {
return bootstrapTokenPrevious;
}
public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) {
this.bootstrapTokenPrevious = bootstrapTokenPrevious;
}
}

View File

@@ -0,0 +1,70 @@
package com.cameleer3.server.app.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link BootstrapTokenValidator}.
* No Spring context needed — implementation constructed directly.
*/
class BootstrapTokenValidatorTest {
@Test
void validate_correctToken_returnsTrue() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("my-secret-token");
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertTrue(validator.validate("my-secret-token"));
}
@Test
void validate_wrongToken_returnsFalse() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("my-secret-token");
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertFalse(validator.validate("wrong-token"));
}
@Test
void validate_previousToken_returnsTrueWhenSet() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("new-token");
props.setBootstrapTokenPrevious("old-token");
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertTrue(validator.validate("old-token"), "Previous token should be accepted during rotation");
}
@Test
void validate_nullToken_returnsFalse() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("my-secret-token");
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertFalse(validator.validate(null));
}
@Test
void validate_blankToken_returnsFalse() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("my-secret-token");
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertFalse(validator.validate(""));
assertFalse(validator.validate(" "));
}
@Test
void validate_previousTokenNotSet_onlyCurrentAccepted() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("current-token");
// bootstrapTokenPrevious is null by default
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertTrue(validator.validate("current-token"));
assertFalse(validator.validate("some-old-token"));
}
}

View File

@@ -0,0 +1,88 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.core.security.Ed25519SigningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link Ed25519SigningServiceImpl}.
* No Spring context needed — implementation constructed directly.
*/
class Ed25519SigningServiceTest {
private Ed25519SigningService signingService;
@BeforeEach
void setUp() {
signingService = new Ed25519SigningServiceImpl();
}
@Test
void getPublicKeyBase64_returnsNonNullBase64String() {
String publicKeyBase64 = signingService.getPublicKeyBase64();
assertNotNull(publicKeyBase64);
assertFalse(publicKeyBase64.isBlank());
// Verify it's valid Base64
assertDoesNotThrow(() -> Base64.getDecoder().decode(publicKeyBase64));
}
@Test
void sign_returnsBase64SignatureString() {
String signature = signingService.sign("test payload");
assertNotNull(signature);
assertFalse(signature.isBlank());
assertDoesNotThrow(() -> Base64.getDecoder().decode(signature));
}
@Test
void sign_signatureVerifiesAgainstPublicKey() throws Exception {
String payload = "important config data";
String signatureBase64 = signingService.sign(payload);
String publicKeyBase64 = signingService.getPublicKeyBase64();
// Reconstruct public key from Base64
byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes));
// Verify signature
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
assertTrue(verifier.verify(Base64.getDecoder().decode(signatureBase64)),
"Signature should verify against the public key");
}
@Test
void sign_differentPayloadsProduceDifferentSignatures() {
String sig1 = signingService.sign("payload one");
String sig2 = signingService.sign("payload two");
assertNotEquals(sig1, sig2, "Different payloads should produce different signatures");
}
@Test
void sign_tamperedPayloadFailsVerification() throws Exception {
String payload = "original payload";
String signatureBase64 = signingService.sign(payload);
String publicKeyBase64 = signingService.getPublicKeyBase64();
byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes));
// Verify against tampered payload
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update("tampered payload".getBytes(java.nio.charset.StandardCharsets.UTF_8));
assertFalse(verifier.verify(Base64.getDecoder().decode(signatureBase64)),
"Tampered payload should fail verification");
}
}

View File

@@ -0,0 +1,86 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.core.security.InvalidTokenException;
import com.cameleer3.server.core.security.JwtService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link JwtServiceImpl}.
* No Spring context needed — implementations are constructed directly.
*/
class JwtServiceTest {
private JwtService jwtService;
@BeforeEach
void setUp() {
SecurityProperties props = new SecurityProperties();
props.setAccessTokenExpiryMs(3_600_000); // 1 hour
props.setRefreshTokenExpiryMs(604_800_000); // 7 days
jwtService = new JwtServiceImpl(props);
}
@Test
void createAccessToken_returnsSignedJwtWithCorrectClaims() {
String token = jwtService.createAccessToken("agent-1", "group-a");
assertNotNull(token);
assertFalse(token.isBlank());
// JWT format: header.payload.signature
assertEquals(3, token.split("\\.").length, "JWT should have 3 parts");
}
@Test
void createAccessToken_canBeValidated() {
String token = jwtService.createAccessToken("agent-1", "group-a");
String agentId = jwtService.validateAndExtractAgentId(token);
assertEquals("agent-1", agentId);
}
@Test
void createRefreshToken_returnsSignedJwt() {
String token = jwtService.createRefreshToken("agent-2", "group-b");
assertNotNull(token);
assertFalse(token.isBlank());
assertEquals(3, token.split("\\.").length, "JWT should have 3 parts");
}
@Test
void createRefreshToken_canBeValidatedWithRefreshMethod() {
String token = jwtService.createRefreshToken("agent-2", "group-b");
String agentId = jwtService.validateRefreshToken(token);
assertEquals("agent-2", agentId);
}
@Test
void validateAndExtractAgentId_rejectsRefreshToken() {
String refreshToken = jwtService.createRefreshToken("agent-3", "group-c");
assertThrows(InvalidTokenException.class, () ->
jwtService.validateAndExtractAgentId(refreshToken),
"Access validation should reject refresh tokens");
}
@Test
void validateRefreshToken_rejectsAccessToken() {
String accessToken = jwtService.createAccessToken("agent-4", "group-d");
assertThrows(InvalidTokenException.class, () ->
jwtService.validateRefreshToken(accessToken),
"Refresh validation should reject access tokens");
}
@Test
void validateAndExtractAgentId_rejectsExpiredToken() {
// Create a service with 0ms expiry to produce already-expired tokens
SecurityProperties shortProps = new SecurityProperties();
shortProps.setAccessTokenExpiryMs(0);
shortProps.setRefreshTokenExpiryMs(604_800_000);
JwtService shortLivedService = new JwtServiceImpl(shortProps);
String token = shortLivedService.createAccessToken("agent-5", "group-e");
assertThrows(InvalidTokenException.class, () ->
jwtService.validateAndExtractAgentId(token),
"Should reject expired tokens");
}
}

View File

@@ -0,0 +1,28 @@
package com.cameleer3.server.core.security;
/**
* Service for Ed25519 digital signatures.
* <p>
* Used to sign configuration and command payloads pushed to agents via SSE,
* allowing agents to verify the authenticity of received data.
* The keypair is ephemeral (generated at startup); agents receive the public
* key during registration.
*/
public interface Ed25519SigningService {
/**
* Signs the given payload using the server's Ed25519 private key.
*
* @param payload the string payload to sign
* @return Base64-encoded signature bytes
*/
String sign(String payload);
/**
* Returns the server's Ed25519 public key as a Base64-encoded string
* (X.509 SubjectPublicKeyInfo DER format).
*
* @return Base64-encoded public key
*/
String getPublicKeyBase64();
}

View File

@@ -0,0 +1,15 @@
package com.cameleer3.server.core.security;
/**
* Thrown when a JWT token is invalid, expired, or of the wrong type.
*/
public class InvalidTokenException extends RuntimeException {
public InvalidTokenException(String message) {
super(message);
}
public InvalidTokenException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,48 @@
package com.cameleer3.server.core.security;
/**
* Service for creating and validating JSON Web Tokens (JWT).
* <p>
* Access tokens are short-lived (default 1 hour) and used for API authentication.
* Refresh tokens are longer-lived (default 7 days) and used to obtain new access tokens.
*/
public interface JwtService {
/**
* Creates a signed access JWT with the given agent ID and group.
*
* @param agentId the agent identifier (becomes the {@code sub} claim)
* @param group the agent group (becomes the {@code group} claim)
* @return a signed JWT string
*/
String createAccessToken(String agentId, String group);
/**
* Creates a signed refresh JWT with the given agent ID and group.
*
* @param agentId the agent identifier (becomes the {@code sub} claim)
* @param group the agent group (becomes the {@code group} claim)
* @return a signed JWT string
*/
String createRefreshToken(String agentId, String group);
/**
* Validates an access token and extracts the agent ID.
* Rejects expired tokens and tokens that are not of type "access".
*
* @param token the JWT string to validate
* @return the agent ID from the {@code sub} claim
* @throws InvalidTokenException if the token is invalid, expired, or not an access token
*/
String validateAndExtractAgentId(String token);
/**
* Validates a refresh token and extracts the agent ID.
* Rejects expired tokens and tokens that are not of type "refresh".
*
* @param token the JWT string to validate
* @return the agent ID from the {@code sub} claim
* @throws InvalidTokenException if the token is invalid, expired, or not a refresh token
*/
String validateRefreshToken(String token);
}