Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/CI_CD_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ default to `permissions: contents: read`; jobs add only what they need.
An install-once job that shares `~/.m2/repository/org/greenbuttonalliance` as an artifact was
rejected. It would add a serial job, plus artifact upload and download, that cost more than the
30 s compile it saves.
- `openespi-authserver` runs only `-Dgroups=testcontainers-it` until **#129** fixes the
unit tests on the H2 profiles.
- `openespi-authserver` runs its full suite (unit tests plus the Testcontainers integration
tests) since **#129**.
- Testcontainers suites use the Docker daemon on `ubuntu-latest`.
- On failure the job uploads `surefire-reports-<module>`. It always uploads that module's
`jacoco.xml` for the `quality` job.
Expand Down Expand Up @@ -190,7 +190,7 @@ create and approve pull requests**. The release workflow needs this to open its

```bash
mvn install -DskipTests -pl openespi-datacustodian -am && mvn verify -pl openespi-datacustodian
mvn verify -pl openespi-authserver -Dgroups=testcontainers-it -DfailIfNoTests=false # until #129
mvn install -DskipTests -pl openespi-authserver -am && mvn verify -pl openespi-authserver
mvn install -DskipTests && mvn org.owasp:dependency-check-maven:aggregate
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color # lint workflows
```
7 changes: 2 additions & 5 deletions .github/scripts/release-meta.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,9 @@ fi

next_version="${major}.${minor}.$((patch + 1))-SNAPSHOT"

# Extra Maven test arguments per module.
# Extra Maven test arguments per module. None today: every module runs its full suite
# (authserver's -Dgroups=testcontainers-it restriction was removed by #129).
test_args=""
if [[ "$module" == authserver ]]; then
# TODO(#129): AS unit tests are broken on the H2 test profiles; only the testcontainers-it suite runs.
test_args="-Dgroups=testcontainers-it -DfailIfNoTests=false"
fi

# Previous release tag of the SAME module (highest version below this one), for release notes.
previous_tag=""
Expand Down
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ jobs:
- module: openespi-datacustodian
- module: openespi-thirdparty
- module: openespi-authserver
# TODO(#129): AS unit tests are broken on the H2 test profiles; only the Docker-backed
# @Tag("testcontainers-it") suite runs until #129 is fixed. Remove this line then.
test-args: -Dgroups=testcontainers-it -DfailIfNoTests=false
steps:
- uses: actions/checkout@v7

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:
run: bash .github/scripts/set-module-version.sh "$DIR" "$VERSION" "$VERSION_PROPERTY"

# Equivalent to `mvn verify -pl <module> -am`, split so that module-specific test arguments
# (authserver's -Dgroups, #129) do not leak into the upstream modules' test runs.
# (TEST_ARGS from release-meta.sh, currently none) do not leak into the upstream modules' test runs.
- name: Verify upstream modules (tests on)
if: env.UPSTREAM != ''
run: mvn install -pl "$UPSTREAM" -am # -am only adds the parent pom
Expand Down
9 changes: 4 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,10 @@ cd openespi-common && mvn test
# There are no failsafe *IT classes and no separate integration-test profile.
mvn test -pl openespi-common

# What CI runs per module (matrix in .github/workflows/ci.yml): install upstream, then verify
# What CI runs per module (matrix in .github/workflows/ci.yml): install upstream, then verify.
# For openespi-authserver this is the full suite (unit + MySQL Testcontainers tests) since #129;
# add -Dgroups=testcontainers-it to run only the Docker-backed integration tests.
mvn install -DskipTests -pl <module> -am && mvn verify -pl <module>

# Authorization Server: only the TestContainers group runs cleanly today (#129)
mvn verify -pl openespi-authserver -Dgroups=testcontainers-it -DfailIfNoTests=false
```

### Run Applications
Expand Down Expand Up @@ -269,7 +268,7 @@ ESPI uses Atom XML feeds for data exchange. Key patterns:
- All tests must pass before committing: `mvn test`
- Run tests for affected module and its dependents: `mvn test -pl <module> -am`
- TestContainers-based tests (Docker required) run inside the normal `mvn test`; there are no failsafe `*IT` classes and no separate integration-test profile (removed in #193)
- Note: openespi-authserver unit tests are currently excluded from CI (#129); CI runs only its `@Tag("testcontainers-it")` tests
- openespi-authserver runs its full suite in CI (#129); its Docker-backed tests are tagged `@Tag("testcontainers-it")` (via `AbstractAuthserverIntegrationTest`) so they can be run alone with `-Dgroups=testcontainers-it`
- **IMPORTANT**: Use AssertJ chained assertions - combine all assertions for a single object into one assertion chain
- Good: `assertThat(retrieved).isPresent().hasValueSatisfying(device -> assertThat(device).extracting(...).containsExactly(...))`
- Bad: Multiple separate `assertThat()` statements for the same object
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ mvn verify -pl openespi-common -am
mvn test -Dtest=ComplexRelationshipMySQLIntegrationTest -pl openespi-common
mvn test -Dtest=ComplexRelationshipPostgreSQLIntegrationTest -pl openespi-common

# Authorization Server: only the Testcontainers suite runs until #129 (same as CI)
mvn verify -pl openespi-authserver -am -Dgroups=testcontainers-it -DfailIfNoTests=false
# Authorization Server: full suite, unit + Testcontainers (MySQL) integration tests (same as CI)
mvn verify -pl openespi-authserver -am
```

CI, release and versioning: see [`.github/CI_CD_SETUP.md`](.github/CI_CD_SETUP.md) and [`RELEASING.md`](RELEASING.md).
Expand Down
2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The parent pom, `openespi-parent`, is not released on its own.
- sets the release version with `versions:set -pl <module>`, **in CI only**; nothing is
committed;
- runs the upstream modules' tests and then `mvn verify -pl <module>`. This is equivalent
to `-am verify`. `authserver` uses `-Dgroups=testcontainers-it` until #129;
to `-am verify`. Every module, `authserver` included, runs its full test suite;
- collects the jar. For applications this is the Spring Boot fat jar.
3. **github-release**: creates the GitHub Release `<tag>` with:
- generated notes, starting from the previous tag of the same module;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,165 @@

package org.greenbuttonalliance.espi.authserver;

import org.greenbuttonalliance.espi.authserver.grant.GrantContextEnrichingAuthorizationService;
import org.greenbuttonalliance.espi.authserver.integration.AbstractAuthserverIntegrationTest;
import org.greenbuttonalliance.espi.authserver.service.EspiTokenCustomizer;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.web.SecurityFilterChain;

import java.time.Duration;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Integration tests for OpenESPI Authorization Server Application
*
* Tests basic Spring Boot application startup and configuration
* with Spring Authorization Server 1.3+.
*
* @author Green Button Alliance
* @version 1.0.0
* @since Spring Boot 3.5
* Application context test for the OpenESPI Authorization Server, booted with the real configuration
* against the shared MySQL Testcontainer (see {@link AbstractAuthserverIntegrationTest}).
*
* <p>Replaces the former H2 {@code local}-profile {@code contextLoads} and the mock-{@code HttpSecurity}
* {@code AuthorizationServerConfigTest} (#129): instead of calling {@code @Bean} methods against mocks,
* it asserts what the started context actually contains.</p>
*/
@SpringBootTest
@ActiveProfiles("local")
class AuthorizationServerApplicationTests {

/**
* Test that the Spring Boot application context loads successfully
* with all OAuth2 Authorization Server configurations.
*/
@DisplayName("Authorization Server application context")
class AuthorizationServerApplicationTests extends AbstractAuthserverIntegrationTest {

@Autowired
private ApplicationContext context;

@Autowired
private AuthorizationServerSettings authorizationServerSettings;

@Autowired
private RegisteredClientRepository registeredClientRepository;

@Autowired
private OAuth2AuthorizationService authorizationService;

@Autowired
private OAuth2TokenCustomizer<JwtEncodingContext> espiTokenCustomizer;

@Test
@DisplayName("registers exactly the five application security filter chains")
void registersTheApplicationSecurityFilterChains() {
// Pinned on purpose: a new chain changes which requests each existing chain sees.
assertThat(context.getBeansOfType(SecurityFilterChain.class))
.containsOnlyKeys(
"actuatorSecurityFilterChain", // #240: health public, the rest authserver.admin
"clientRegistrationSecurityFilterChain",
"authorizationServerSecurityFilterChain",
"adminApiBearerSecurityFilterChain",
"defaultSecurityFilterChain");
}

@Test
@DisplayName("is opaque-token only: no JwtDecoder or JwtEncoder bean (#134)")
void hasNoJwtInfrastructure() {
assertThat(context.getBeanNamesForType(JwtDecoder.class)).isEmpty();
assertThat(context.getBeanNamesForType(JwtEncoder.class)).isEmpty();
}

@Test
@DisplayName("exposes the configured issuer and OAuth2 protocol endpoints")
void authorizationServerSettings() {
assertThat(authorizationServerSettings)
.extracting(
AuthorizationServerSettings::getIssuer,
AuthorizationServerSettings::getAuthorizationEndpoint,
AuthorizationServerSettings::getTokenEndpoint,
AuthorizationServerSettings::getTokenRevocationEndpoint,
AuthorizationServerSettings::getTokenIntrospectionEndpoint)
.containsExactly(
"http://localhost:9999",
"/oauth2/authorize",
"/oauth2/token",
"/oauth2/revoke",
"/oauth2/introspect");
}

@Test
@DisplayName("wires the JDBC client repository, the grant-context authorization service and the ESPI customizer")
void persistenceAndCustomizerBeans() {
assertThat(registeredClientRepository).isInstanceOf(JdbcRegisteredClientRepository.class);
assertThat(authorizationService).isInstanceOf(GrantContextEnrichingAuthorizationService.class);
assertThat(espiTokenCustomizer).isInstanceOf(EspiTokenCustomizer.class);
}

@Test
@DisplayName("seeds data_custodian_admin: client_credentials, opaque, 60 min, no consent")
void seedsDataCustodianAdminClient() {
assertThat(registeredClientRepository.findByClientId("data_custodian_admin"))
.isNotNull()
.satisfies(client -> assertThat(client)
.extracting(
RegisteredClient::getAuthorizationGrantTypes,
RegisteredClient::getScopes,
c -> c.getTokenSettings().getAccessTokenFormat(),
c -> c.getTokenSettings().getAccessTokenTimeToLive(),
c -> c.getClientSettings().isRequireAuthorizationConsent())
.containsExactly(
Set.of(AuthorizationGrantType.CLIENT_CREDENTIALS),
Set.of("DataCustodian_Admin_Access"),
OAuth2TokenFormat.REFERENCE,
Duration.ofMinutes(60),
false));
}

@Test
@DisplayName("seeds third_party: authorization_code + refresh_token, ESPI scopes, opaque, 6 h, consent, no PKCE")
void seedsThirdPartyClient() {
assertThat(registeredClientRepository.findByClientId("third_party"))
.isNotNull()
.satisfies(client -> assertThat(client)
.extracting(
RegisteredClient::getAuthorizationGrantTypes,
c -> c.getTokenSettings().getAccessTokenFormat(),
c -> c.getTokenSettings().getAccessTokenTimeToLive(),
c -> c.getClientSettings().isRequireAuthorizationConsent(),
c -> c.getClientSettings().isRequireProofKey())
.containsExactly(
Set.of(AuthorizationGrantType.AUTHORIZATION_CODE,
AuthorizationGrantType.REFRESH_TOKEN),
OAuth2TokenFormat.REFERENCE,
Duration.ofHours(6),
true,
false))
.extracting(RegisteredClient::getScopes)
.satisfies(scopes -> assertThat(scopes).contains(
"openid", "profile",
"FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13",
"FB=4_5_15;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13"));
}

@Test
void contextLoads() {
// This test verifies that:
// - Spring Boot application starts successfully
// - All OAuth2 Authorization Server beans are configured
// - Database schema is created (H2 in-memory for tests)
// - JWT key pair is generated
// - Registered clients are loaded
// - Security configurations are applied
@DisplayName("seeds third_party_admin and the datacustodian introspection client with opaque tokens")
void seedsRemainingDefaultClients() {
assertThat(registeredClientRepository.findByClientId("third_party_admin"))
.isNotNull()
.extracting(
RegisteredClient::getAuthorizationGrantTypes,
RegisteredClient::getScopes,
c -> c.getTokenSettings().getAccessTokenFormat())
.containsExactly(
Set.of(AuthorizationGrantType.CLIENT_CREDENTIALS),
Set.of("ThirdParty_Admin_Access"),
OAuth2TokenFormat.REFERENCE);
assertThat(registeredClientRepository.findByClientId("datacustodian"))
.isNotNull()
.extracting(RegisteredClient::getAuthorizationGrantTypes)
.isEqualTo(Set.of(AuthorizationGrantType.CLIENT_CREDENTIALS));
}
}
}
Loading
Loading