Ich bin faul. Zumindest, wenn es ums Schreiben von Tests geht, möchte ich möglichst wenig Aufwand mit möglichst großem Benefit. In meinen Projekten kommen oft rollenbasierte Berechtigungen für unsere REST-Endpunkte zum Einsatz, die sich einfach per Annotation deklarieren lassen:
@GET
@Produces(MediaType.TEXT_PLAIN)
@RolesAllowed({Role.RoleNames.USER, Role.RoleNames.ADMIN})
public String hello() {
return "Hello from Quarkus REST";
}
Mit der entsprechenden Abhängigkeit im Projekt (quarkus-test-security) lässt sich das Ganze auch prima in einem Test-Case validieren:
@Test
@TestSecurity(user = "testUser", roles = Role.RoleNames.USER)
public void testWithStandardAnnotation() {
given()
.when()
.get("/hello")
.then()
.statusCode(200)
.body(is("Hello from Quarkus REST"));
}
Diesen Test müsste ich nun eigentlich auch für den Admin wiederholen und eigentlich müsste ich nun auch prüfen, dass jede einzelne andere Rolle hier abgewiesen wird. Mein KI-Tool erzeugt die passenden Tests natürlich innerhalb von 20,5 Sekunden, also gar kein Problem. Das Resultat ist aber eine unübersichtliche Anzahl von Test-Fällen, die ich weiterhin pflegen muss. Wünschenswert wäre doch vielmehr:

- Deklaration der erlaubten Rollen
- = einzelne Validierung der erlaubten Rollen (200)
- = einzelne Validierung der nicht erlaubten Rollen (403)
- = Validierung von anonymem Zugriff (401)
Die grundlegendsten aller Berechtigungsprüfungen sind erledigt. Und das mit einer einzelnen Annotation.
Das Faultier hat gesiegt. Bevor wir uns jetzt aber wieder einem Schläfchen widmen, schauen wir uns noch an, wie das Ganze funktioniert.
@TestRoleSecurity
Die grundlegende Idee ist die Implementierung einer eigenen ArgumentSource, die basierend auf den definierten Rollen unsere Testmethoden mit entsprechenden Parametern, aber auch mit einer entsprechenden Security-Konfiguration aufruft. @TestRoleSecurity ist erst einmal eine eigene Annotation, die einige Parameter entgegennimmt, um das Verhalten ggf. an konkreten Stellen anzupassen. Spannender ist die Verwendung der Annotation: @ArgumentsSource, die unsere Annotation mit ArgumentSource verbindet, wie wir gleich sehen werden:
@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@ArgumentsSource(TestRoleSecurityProvider.class)
public @interface TestRoleSecurity {
// die Testmethode wird in aller Regel alle erlaubten Rollen angeben,
// ggf. macht es aber auch mal Sinn die nicht erlaubten zu definieren
Role[] allowedFor() default { };
Role[] deniedFor() default { };
int returnCodeAllowed() default 200;
int returnCodeDenied() default 403;
int returnCodeNotAuthorized() default 401;
boolean testAllRoles() default true;
boolean testNotAuthorized() default true;
}
TestRoleSecurityProvider
Die Implementierung sieht nun wie folgt aus: Grundsätzlich werden, basierend auf den in der Annotation angegebenen Rollen, die Aufrufe / Argumente der Testmethode definiert. Beim Durchlauf des Argumenten-Streams wird die entsprechende Rolle im Security Context gesetzt:
public class TestRoleSecurityProvider extends AnnotationBasedArgumentsProvider<TestRoleSecurity> {
@Override
protected Stream<? extends Arguments> provideArguments(ParameterDeclarations parameters,
ExtensionContext context,
TestRoleSecurity roleSource) {
return getArgumentsFromSource(roleSource);
}
private Stream<Arguments> getArgumentsFromSource(TestRoleSecurity roleSource) {
// Mapping der definierten Rollen auf ihre StatusCodes (erlaubt / nicht erlaubt)
Map<Role, Integer> rolesToStatusCode = new EnumMap<>(Role.class);
Arrays.stream(roleSource.allowedFor()).forEach(allowedRole -> rolesToStatusCode.put(allowedRole, roleSource.returnCodeAllowed()));
Arrays.stream(roleSource.deniedFor()).forEach(deniedRole -> rolesToStatusCode.put(deniedRole, roleSource.returnCodeDenied()));
// Restliche Rollen / Anonym ergänzen
if (roleSource.testAllRoles()) {
Arrays.stream(Role.values()).filter(r -> !rolesToStatusCode.containsKey(r)).forEach(role -> rolesToStatusCode.put(role, roleSource.returnCodeDenied()));
}
var additional = Stream.of(roleSource.testNotAuthorized() ? Arguments.of(null, roleSource.returnCodeNotAuthorized()) : null);
return Stream.concat
(
additional,
rolesToStatusCode.entrySet().stream().map(e -> Arguments.of(e.getKey(), e.getValue()))
)
.filter(Objects::nonNull).peek(p -> auth(p.get()[0]));
}
/**
* Erzeugt und registriert einen Principal basierend auf dem Argument-Stream
*
* @param role
*/
private void auth(Object role) {
final ArcContainer container = Arc.container();
container.select(TestAuthController.class).get().setEnabled(true);
SecurityIdentity user = null;
if (role != null) {
user = QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal("TestUser"))
.addRoles(Collections.singleton(role.toString())).build();
}
container.select(TestIdentityAssociation.class).get().setTestIdentity(user);
}
}
Verwendung
Wir können in unseren Tests nun entweder zwei unterschiedliche Testmethoden implementieren, um Fehler- und Erfolgsfall abzudecken oder via REST Assured auch die inhaltliche Prüfung nur dann ausführen, wenn wir eine 200er Response haben:
@ParameterizedTest
@TestRoleSecurity(allowedFor = {Role.USER, Role.ADMIN}, testAllRoles = false, testNotAuthorized = false)
void whenAllowedTextShouldBeReturned(Role role, int returnCode) {
given()
.when().get("/hello")
.then()
.statusCode(returnCode);
}
@ParameterizedTest
@TestRoleSecurity(allowedFor = {Role.USER, Role.ADMIN})
public void whenAllowedTextShouldBeReturned(Role role, int returnCode) {
Response response = given()
.when()
.get("/hello")
.then().statusCode(returnCode)
.extract().response();
if (response.statusCode() == 200) {
// inhaltliche Prüfung
assertThat(response.body().print(), is("Hello from Quarkus REST"));
}
}







