1
0

credentials.totpSecret as array and update credentials scenario test (#186)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/186
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig
2025-07-15 11:53:26 +02:00
parent 97017c1b99
commit 3aab0ba3c2
16 changed files with 192 additions and 53 deletions

View File

@@ -9,7 +9,7 @@ classDiagram
class Credentials{
+totpSecret: text
+telephonePassword: text
+phonePassword: text
+emailAdress: text
+smsNumber: text
-active: bool [r/w]

View File

@@ -8,6 +8,7 @@ import java.util.function.BiConsumer;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ContextResource;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.accounts.generated.api.v1.api.CredentialsApi;
@@ -67,12 +68,12 @@ public class HsCredentialsController implements CredentialsApi {
final UUID credentialsUuid) {
context.assumeRoles(assumedRoles);
final var credentials = credentialsRepo.findByUuid(credentialsUuid);
if (credentials.isEmpty()) {
final var credentialsEntity = credentialsRepo.findByUuid(credentialsUuid);
if (credentialsEntity.isEmpty()) {
return ResponseEntity.notFound().build();
}
final var result = mapper.map(
credentials.get(), CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
credentialsEntity.get(), CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@@ -192,6 +193,7 @@ public class HsCredentialsController implements CredentialsApi {
mapper.map(person, HsOfficePersonResource.class)
)
);
resource.setContexts(mapper.mapList(entity.getLoginContexts().stream().toList(), ContextResource.class));
};
final BiConsumer<CredentialsInsertResource, HsCredentialsEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {

View File

@@ -11,6 +11,7 @@ import net.hostsharing.hsadminng.repr.Stringifyable;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@@ -30,7 +31,7 @@ public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Str
protected static Stringify<HsCredentialsEntity> stringify = stringify(HsCredentialsEntity.class, "credentials")
.withProp(HsCredentialsEntity::isActive)
.withProp(HsCredentialsEntity::getEmailAddress)
.withProp(HsCredentialsEntity::getTotpSecret)
.withProp(HsCredentialsEntity::getTotpSecrets)
.withProp(HsCredentialsEntity::getPhonePassword)
.withProp(HsCredentialsEntity::getSmsNumber)
.quotedValues(false);
@@ -66,7 +67,7 @@ public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Str
private String onboardingToken;
@Column
private String totpSecret;
private List<String> totpSecrets;
@Column
private String phonePassword;
@@ -106,4 +107,5 @@ public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Str
public String toString() {
return stringify.apply(this);
}
}

View File

@@ -4,6 +4,7 @@ import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CredentialsPatc
import net.hostsharing.hsadminng.mapper.EntityPatcher;
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import java.util.Optional;
public class HsCredentialsEntityPatcher implements EntityPatcher<CredentialsPatchResource> {
@@ -22,8 +23,8 @@ public class HsCredentialsEntityPatcher implements EntityPatcher<CredentialsPatc
}
OptionalFromJson.of(resource.getEmailAddress())
.ifPresent(entity::setEmailAddress);
OptionalFromJson.of(resource.getTotpSecret())
.ifPresent(entity::setTotpSecret);
Optional.ofNullable(resource.getTotpSecrets())
.ifPresent(entity::setTotpSecrets);
OptionalFromJson.of(resource.getSmsNumber())
.ifPresent(entity::setSmsNumber);
OptionalFromJson.of(resource.getPhonePassword())

View File

@@ -14,9 +14,11 @@ components:
nickname:
type: string
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
totpSecret:
type: string
telephonePassword:
totpSecrets:
type: array
items:
type: string
phonePassword:
type: string
emailAddress:
type: string
@@ -46,9 +48,10 @@ components:
CredentialsPatch:
type: object
properties:
totpSecret:
type: string
nullable: true
totpSecrets:
type: array
items:
type: string
phonePassword:
type: string
nullable: true
@@ -75,9 +78,11 @@ components:
nickname:
type: string
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
totpSecret:
type: string
telephonePassword:
totpSecrets:
type: array
items:
type: string
phonePassword:
type: string
emailAddress:
type: string

View File

@@ -18,7 +18,7 @@ create table hs_accounts.credentials
global_gid int unique, -- w/o
onboarding_token text, -- w/o, but can be set to null to invalidate
totp_secret text,
totp_secrets text[],
phone_password text,
email_address text,
sms_number text

View File

@@ -51,9 +51,9 @@ begin
-- call rbac.grantRoleToRole(hs_accounts.context_REFERRER(context_MATRIX_internal), rbac.global_ADMIN());
-- Add test credentials (linking to assumed rbac.subject UUIDs)
INSERT INTO hs_accounts.credentials (uuid, version, person_uuid, active, global_uid, global_gid, onboarding_token, totp_secret, phone_password, email_address, sms_number) VALUES
( superuserAlexSubjectUuid, 0, personAlexUuid, true, 1001, 1001, 'token-abc', 'otp-secret-1', 'phone-pw-1', 'alex@example.com', '111-222-3333'),
( superuserFranSubjectUuid, 0, personFranUuid, true, 1002, 1002, 'token-def', 'otp-secret-2', 'phone-pw-2', 'fran@example.com', '444-555-6666');
INSERT INTO hs_accounts.credentials (uuid, version, person_uuid, active, global_uid, global_gid, onboarding_token, totp_secrets, phone_password, email_address, sms_number) VALUES
( superuserAlexSubjectUuid, 0, personAlexUuid, true, 1001, 1001, 'token-abc', ARRAY['otp-secret-1a', 'otp-secret-1b'], 'phone-pw-1', 'alex@example.com', '111-222-3333'),
( superuserFranSubjectUuid, 0, personFranUuid, true, 1002, 1002, 'token-def', ARRAY['otp-secret-2'], 'phone-pw-2', 'fran@example.com', '444-555-6666');
-- Map credentials to contexts
INSERT INTO hs_accounts.context_mapping (credentials_uuid, context_uuid) VALUES

View File

@@ -33,13 +33,13 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase<
private static final Boolean INITIAL_ACTIVE = true;
private static final String INITIAL_EMAIL_ADDRESS = "initial@example.com";
private static final String INITIAL_TOTP_SECRET = "initial_2fa";
private static final List<String> INITIAL_TOTP_SECRETS = List.of("initial_2fa");
private static final String INITIAL_SMS_NUMBER = "initial_sms";
private static final String INITIAL_PHONE_PASSWORD = "initial_phone_pw";
private static final Boolean PATCHED_ACTIVE = false;
private static final String PATCHED_EMAIL_ADDRESS = "patched@example.com";
private static final String PATCHED_TOTP_SECRET = "patched_2fa";
private static final List<String> PATCHED_TOTP_SECRETS = List.of("patched_2fa");
private static final String PATCHED_SMS_NUMBER = "patched_sms";
private static final String PATCHED_PHONE_PASSWORD = "patched_phone_pw";
@@ -102,7 +102,7 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase<
entity.setUuid(INITIAL_CREDENTIALS_UUID);
entity.setActive(INITIAL_ACTIVE);
entity.setEmailAddress(INITIAL_EMAIL_ADDRESS);
entity.setTotpSecret(INITIAL_TOTP_SECRET);
entity.setTotpSecrets(INITIAL_TOTP_SECRETS);
entity.setSmsNumber(INITIAL_SMS_NUMBER);
entity.setPhonePassword(INITIAL_PHONE_PASSWORD);
// Ensure loginContexts is a mutable set for the patcher
@@ -137,12 +137,13 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase<
PATCHED_EMAIL_ADDRESS,
HsCredentialsEntity::setEmailAddress,
PATCHED_EMAIL_ADDRESS),
new JsonNullableProperty<>(
new SimpleProperty<>(
"totpSecret",
CredentialsPatchResource::setTotpSecret,
PATCHED_TOTP_SECRET,
HsCredentialsEntity::setTotpSecret,
PATCHED_TOTP_SECRET),
CredentialsPatchResource::setTotpSecrets,
PATCHED_TOTP_SECRETS,
HsCredentialsEntity::setTotpSecrets,
PATCHED_TOTP_SECRETS)
.notNullable(),
new JsonNullableProperty<>(
"smsNumber",
CredentialsPatchResource::setSmsNumber,

View File

@@ -0,0 +1,38 @@
package net.hostsharing.hsadminng.hs.accounts.scenarios;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ContextResource;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.scenarios.UseCase;
import org.apache.commons.lang3.tuple.Pair;
import java.util.Arrays;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK;
public abstract class BaseCredentialsUseCase<T extends UseCase<?>> extends UseCase<T> {
public BaseCredentialsUseCase(final ScenarioTest testSuite) {
super(testSuite);
}
@SneakyThrows
protected ContextResource[] fetchContextResourcesByDescriptorPairs(final String descriptPairsVarName) {
final var requestedContexts = ScenarioTest.getTypedVariable("contexts", Pair[].class);
final var existingContextsJson = withTitle("Fetch Available Account Contexts", () ->
httpGet("/api/hs/accounts/contexts").expecting(OK).expecting(JSON)
).getResponse().body();
final var existingContexts = objectMapper.readValue(existingContextsJson, ContextResource[].class);
return Arrays.stream(requestedContexts)
.map(pair -> Arrays.stream(existingContexts)
.filter(context -> context.getType().equals(pair.getLeft())
&& context.getQualifier().equals(pair.getRight()))
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"No matching context found for type=" + pair.getLeft()
+ " and qualifier=" + pair.getRight()))
)
.toArray(ContextResource[]::new);
}
}

View File

@@ -8,7 +8,7 @@ import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK;
public class CreateCredentials extends UseCase<CreateCredentials> {
public class CreateCredentials extends BaseCredentialsUseCase<CreateCredentials> {
public CreateCredentials(final ScenarioTest testSuite) {
super(testSuite);
@@ -26,9 +26,8 @@ public class CreateCredentials extends UseCase<CreateCredentials> {
"In real situations we have more precise measures to find the related person."
);
obtain("CredentialsContexts", () ->
httpGet("/api/hs/accounts/contexts").expecting(OK).expecting(JSON)
given("resolvedContexts",
fetchContextResourcesByDescriptorPairs("contexts")
);
return obtain("newCredentials", () ->
@@ -37,12 +36,14 @@ public class CreateCredentials extends UseCase<CreateCredentials> {
"person.uuid": ${Person: %{personGivenName} %{personFamilyName}},
"nickname": ${nickname},
"active": %{active},
"totpSecrets": @{totpSecrets},
"emailAddress": ${emailAddress},
"telephonePassword": ${telephonePassword},
"phonePassword": ${phonePassword},
"smsNumber": ${smsNumber},
"onboardingToken": ${onboardingToken},
"globalUid": %{globalUid},
"globalGid": %{globalGid},
"contexts": @{contexts}
"contexts": @{resolvedContexts}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
@@ -57,7 +58,9 @@ public class CreateCredentials extends UseCase<CreateCredentials> {
.expecting(OK).expecting(JSON),
path("uuid").contains("%{newCredentials}"),
path("nickname").contains("%{nickname}"),
path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}")
path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}"),
path("totpSecrets").contains("@{totpSecrets}"),
path("onboardingToken").contains("%{onboardingToken}")
);
}
}

View File

@@ -4,10 +4,12 @@ import lombok.SneakyThrows;
import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.config.DisableSecurityConfig;
import net.hostsharing.hsadminng.hs.scenarios.Produces;
import net.hostsharing.hsadminng.hs.scenarios.Requires;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.mapper.Array;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import net.hostsharing.hsadminng.test.IgnoreOnFailureExtension;
import org.apache.commons.lang3.tuple.Pair;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.MethodOrderer;
@@ -22,8 +24,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.util.Map;
@Tag("scenarioTest")
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
@@ -45,6 +45,7 @@ class CredentialsScenarioTests extends ScenarioTest {
protected void beforeScenario(final TestInfo testInfo) {
super.beforeScenario(testInfo);
}
@Nested
@Order(10)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@@ -62,22 +63,38 @@ class CredentialsScenarioTests extends ScenarioTest {
.given("nickname", "firby-susan")
// initial credentials
.given("active", true)
.given("totpSecrets", Array.of("initialSecret"))
.given("emailAddress", "susan.firby@example.com")
.given("telephonePassword", "securePass123")
.given("phonePassword", "securePass123")
.given("smsNumber", "+49123456789")
.given("globalUid", 21011)
.given("globalGid", 21011)
.given("contexts", Array.of(
Map.ofEntries(
// a hardcoded context from test-data
// TODO.impl: the uuid should be determined within CreateCredentials just by (HSDAMIN,prod)
Map.entry("uuid", "11111111-1111-1111-1111-111111111111"),
Map.entry("type", "HSADMIN"),
Map.entry("qualifier", "prod")
)
Pair.of("HSADMIN", "prod")
))
.given("onboardingToken", "fake-unboarding-token")
.doRun()
.keep();
}
@Test
@Order(1020)
@Requires("Credentials@hsadmin: firby-susan")
void shouldUpdateCredentials() {
new UpdateCredentials(scenarioTest)
// the credentials to update
.given("credentialsUuid", "%{Credentials@hsadmin: firby-susan}")
// updated credentials
.given("active", false)
.given("totpSecrets", Array.of("initialSecret", "additionalSecret"))
.given("emailAddress", "susan.firby@example.org")
.given("phonePassword", "securePass987")
.given("smsNumber", "+49987654321")
.given("contexts", Array.of(
Pair.of("HSADMIN", "prod"),
Pair.of("SSH", "internal")
))
.doRun();
}
}
}

View File

@@ -0,0 +1,56 @@
package net.hostsharing.hsadminng.hs.accounts.scenarios;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.scenarios.UseCase;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK;
public class UpdateCredentials extends BaseCredentialsUseCase<UpdateCredentials> {
public UpdateCredentials(final ScenarioTest testSuite) {
super(testSuite);
introduction("A set of credentials contains the login data for an RBAC subject.");
}
@Override
protected HttpResponse run() {
given("resolvedContexts",
fetchContextResourcesByDescriptorPairs("contexts")
);
withTitle("Patch the Changes to the existing Credentials", () ->
httpPatch("/api/hs/accounts/credentials/%{credentialsUuid}", usingJsonBody("""
{
"active": %{active},
"totpSecrets": @{totpSecrets},
"emailAddress": ${emailAddress},
"phonePassword": ${phonePassword},
"smsNumber": ${smsNumber},
"contexts": @{resolvedContexts}
}
"""))
.reportWithResponse().expecting(HttpStatus.OK).expecting(ContentType.JSON)
.extractValue("nickname", "nickname")
.extractValue("totpSecrets", "totpSecrets")
);
return null;
}
@Override
protected void verify(final UseCase<UpdateCredentials>.HttpResponse response) {
verify(
"Verify the Patched Credentials",
() -> httpGet("/api/hs/accounts/credentials/%{credentialsUuid}")
.expecting(OK).expecting(JSON),
path("uuid").contains("%{newCredentials}"),
path("nickname").contains("%{nickname}"),
path("totpSecrets").contains("%{totpSecrets}")
);
}
}

View File

@@ -18,12 +18,13 @@ public class PathAssertion {
@SuppressWarnings({ "unchecked", "rawtypes" })
public Consumer<UseCase.HttpResponse> contains(final String resolvableValue) {
final var resolvedValue = ScenarioTest.resolve(resolvableValue, DROP_COMMENTS);
return response -> {
try {
response.path(path).isEqualTo(ScenarioTest.resolve(resolvableValue, DROP_COMMENTS));
response.path(path).isEqualTo(resolvedValue);
} catch (final AssertionError e) {
// without this, the error message is often lacking important context
fail(e.getMessage() + " in `path(\"" + path + "\").contains(\"" + resolvableValue + "\")`" );
fail(e.getMessage() + " in `path(\"" + path + "\").contains(\"" + resolvedValue + "\")`" );
}
};
}

View File

@@ -66,13 +66,13 @@ public abstract class ScenarioTest extends ContextBasedTest {
keepProducesAlias(currentTestMethod);
});
testReport.createTestLogMarkdownFile(testInfo);
} catch (Exception exc) {
} catch (final Exception exc) {
throw exc;
}
}
@AfterEach
void afterScenario(final TestInfo testInfo) { // final TestInfo testInfo
void afterScenario(final TestInfo testInfo) {
verifyProduceDeclaration(testInfo);
properties.clear();
@@ -191,7 +191,7 @@ public abstract class ScenarioTest extends ContextBasedTest {
properties.remove(propName);
}
static Map<String, Object> knowVariables() {
public static Map<String, Object> knowVariables() {
final var map = new LinkedHashMap<String, Object>();
map.putAll(ScenarioTest.aliases);
map.putAll(ScenarioTest.properties);
@@ -223,4 +223,13 @@ public abstract class ScenarioTest extends ContextBasedTest {
return (T) resolvedValue;
}
public static <T> T getTypedVariable(final String varName, final Class<T> expectedValueClass) {
final var value = knowVariables().get(varName);
if (value != null && !expectedValueClass.isAssignableFrom(value.getClass())) {
throw new IllegalArgumentException("variable '" + varName + "'" +
" expected to be of type " + expectedValueClass + " " +
" but got " + value.getClass());
}
return (T) value;
}
}

View File

@@ -1,5 +1,7 @@
package net.hostsharing.hsadminng.hs.scenarios;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import java.net.URLEncoder;
@@ -17,6 +19,7 @@ import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.D
public class TemplateResolver {
public static final String JSON_NULL_VALUE_TO_KEEP = "NULL";
public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public enum Resolver {
DROP_COMMENTS, // deletes comments ('#{whatever}' -> '')
@@ -230,6 +233,7 @@ public class TemplateResolver {
};
}
@SneakyThrows
private static String jsonObject(final Object value) {
return switch (value) {
case null -> "null";
@@ -237,7 +241,7 @@ public class TemplateResolver {
.map(entry -> "\"" + entry.getKey() + "\": " + jsonQuoted(entry.getValue()))
.collect(Collectors.joining(", ")) + "}";
case String string -> "{" + string.replace("\n", " ") + "}";
default -> throw new IllegalArgumentException("can not format " + value.getClass() + " (" + value + ") as JSON object");
default -> OBJECT_MAPPER.writeValueAsString(value);
};
}

View File

@@ -47,7 +47,7 @@ public abstract class UseCase<T extends UseCase<?>> {
private static final HttpClient client = HttpClient.newHttpClient();
private static final int HTTP_TIMEOUT_SECONDS = 20; // FIXME: configurable in environment
private final ObjectMapper objectMapper = new ObjectMapper();
protected final ObjectMapper objectMapper = new ObjectMapper();
protected final ScenarioTest testSuite;
private final TestReport testReport;