diff --git a/build.gradle.kts b/build.gradle.kts index b3796eb..eda8aee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation("io.micronaut:micronaut-http-client") implementation("io.micronaut.picocli:micronaut-picocli") implementation("io.micronaut.serde:micronaut-serde-jackson") + implementation("io.micronaut:micronaut-retry") testImplementation("net.datafaker:datafaker:2.5.1") compileOnly("org.projectlombok:lombok") runtimeOnly("ch.qos.logback:logback-classic") diff --git a/src/main/java/org/justserve/cli/JustServeCommand.java b/src/main/java/org/justserve/cli/JustServeCommand.java index 67a472f..6fb1f7c 100644 --- a/src/main/java/org/justserve/cli/JustServeCommand.java +++ b/src/main/java/org/justserve/cli/JustServeCommand.java @@ -3,12 +3,13 @@ import io.micronaut.configuration.picocli.PicocliRunner; import org.justserve.cli.command.BaseCommand; import org.justserve.cli.command.GetTempPassword; +import org.justserve.cli.command.MakeOrgAdmin; import org.justserve.cli.util.JustServeVersionProvider; import picocli.CommandLine.Command; import picocli.CommandLine.ParameterException; import picocli.jansi.graalvm.AnsiConsole; -@Command(subcommands = GetTempPassword.class, +@Command(subcommands = {GetTempPassword.class, MakeOrgAdmin.class}, mixinStandardHelpOptions = true, name = "justserve", versionProvider = JustServeVersionProvider.class, description = "justserve-cli is a terminal tool to help specialists and admin using JustServe") diff --git a/src/main/java/org/justserve/cli/command/BaseCommand.java b/src/main/java/org/justserve/cli/command/BaseCommand.java index 29da8f6..ad5215a 100644 --- a/src/main/java/org/justserve/cli/command/BaseCommand.java +++ b/src/main/java/org/justserve/cli/command/BaseCommand.java @@ -1,5 +1,6 @@ package org.justserve.cli.command; +import io.micronaut.context.annotation.Value; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.ReflectiveAccess; import picocli.CommandLine.Command; @@ -9,15 +10,28 @@ import java.io.PrintWriter; import java.util.Optional; +import static org.justserve.cli.util.JustServePrinter.printError; import static picocli.CommandLine.Help.Ansi.AUTO; @Command -public class BaseCommand implements ConsoleOutput{ +public class BaseCommand implements ConsoleOutput { @Spec @ReflectiveAccess protected CommandSpec spec; + @Value("${justserve.token}") + String token; + + boolean validateToken() { + if ("i-need-to-be-defined".equals(token) || null == token) { + printError(("NO AUTHENTICATION PROVIDED" + System.lineSeparator() + + "The Authentication token is not assigned as an environment variable." + System.lineSeparator() + + "Please define the environment variable \"JUSTSERVE_TOKEN\" and try again.")); + return false; + } + return true; + } @Override public void out(String message) { diff --git a/src/main/java/org/justserve/cli/command/GetTempPassword.java b/src/main/java/org/justserve/cli/command/GetTempPassword.java index 399d28a..5c9b4b4 100644 --- a/src/main/java/org/justserve/cli/command/GetTempPassword.java +++ b/src/main/java/org/justserve/cli/command/GetTempPassword.java @@ -1,11 +1,10 @@ package org.justserve.cli.command; -import io.micronaut.context.annotation.Value; -import io.micronaut.core.annotation.ReflectiveAccess; import io.micronaut.http.HttpResponse; import io.micronaut.http.client.exceptions.HttpClientResponseException; import jakarta.inject.Inject; import jakarta.inject.Provider; +import jakarta.validation.constraints.Email; import org.justserve.client.UserClient; import org.justserve.model.UserHashRequestByEmail; import picocli.CommandLine.Command; @@ -17,26 +16,20 @@ @Command(name = "getTempPassword", description = "get a temporary password for a user") public class GetTempPassword extends BaseCommand implements Runnable { - @ReflectiveAccess + @Email @Option(names = {"-e", "--email"}, description = "email for the user whose temporary password will be generated") String email; @Inject - @ReflectiveAccess Provider userClientProvider; - @Value("${justserve.token}") - String token; @Override public void run() { - HttpResponse response; - if ("i-need-to-be-defined".equals(token) || null == token) { - printError(("NO AUTHENTICATION PROVIDED" + System.lineSeparator() + - "The Authentication token is not assigned as an environment variable." + System.lineSeparator() + - "Please define the environment variable \"JUSTSERVE_TOKEN\" and try again.")); + if (!validateToken()) { return; } + HttpResponse response; try { UserClient userClient = userClientProvider.get(); response = userClient.getTempPassword(new UserHashRequestByEmail(email)); diff --git a/src/main/java/org/justserve/cli/command/MakeOrgAdmin.java b/src/main/java/org/justserve/cli/command/MakeOrgAdmin.java new file mode 100644 index 0000000..a1b6f95 --- /dev/null +++ b/src/main/java/org/justserve/cli/command/MakeOrgAdmin.java @@ -0,0 +1,121 @@ +package org.justserve.cli.command; + +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import jakarta.inject.Inject; +import jakarta.inject.Provider; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; +import org.justserve.client.BoundaryPermissionClient; +import org.justserve.client.DynamicRoutingClient; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.util.*; +import java.util.stream.Collectors; + +import static org.justserve.cli.util.JustServePrinter.printError; +import static org.justserve.cli.util.JustServePrinter.printNormal; + +@Slf4j +@Command(name = "makeOrgAdmin", description = "make a user an admin for the provided organization(s). " + + "Makes no changes to the user's boundaries.", mixinStandardHelpOptions = true) +public class MakeOrgAdmin extends BaseCommand implements Runnable { + + @Option(names = {"--user", "-u"}, description = "the user who will be made org admin") + private UUID user; + + @Option(names = {"-o", "--org"}, description = "The organization(s) specified by URL slug or UID.", + converter = OrgConverter.class, arity = "1..*", split = ",") + private Org[] orgs; + + @Inject + Provider dynamicRoutingClientProvider; + + @Inject + Provider boundaryPermissionClientProvider; + + @Override + public void run() { + if (!validateToken()) { + return; + } + DynamicRoutingClient dynamicRoutingClient = dynamicRoutingClientProvider.get(); + // since we allow submitting orgId's and orgUrl, convert any slugs to orgId's + Map orgUuidMap = Arrays.stream(orgs).distinct().parallel() + .collect(Collectors.toMap( + org -> org, + org -> org instanceof OrgId ? ((OrgId) org).getId() : dynamicRoutingClient + .getOrgIdFromSlug(((OrgSlug) org).getSlug()).body().getId() + )); + if (orgUuidMap.values().stream().anyMatch(Objects::isNull)) { + // provide a list of all invalid org slugs, not just the first failure + List invalidOrgSlugs = new ArrayList<>(); + orgUuidMap.entrySet().stream() + .filter(entry -> entry.getValue() == null) + .map(entry -> (OrgSlug) entry.getKey()) + .forEach(invalidOrgSlugs::add); + printError("The following organization slugs are invalid: " + invalidOrgSlugs.stream() + .map(OrgSlug::getSlug) + .collect(Collectors.joining(", "))); + return; + } + log.atTrace().log("Finished converting any slugs to org id's."); + BoundaryPermissionClient boundaryPermissionClient = boundaryPermissionClientProvider.get(); + Map successfulReassignments = new HashMap<>(); + orgUuidMap.entrySet().stream().parallel().forEach(entry -> { + Org org = entry.getKey(); + UUID orgId = entry.getValue(); + String orgIdentifier = org instanceof OrgSlug ? ((OrgSlug) org).getSlug() + " (" + orgId + ")" : orgId.toString(); + + log.atTrace().log("Making user {} an admin for org {}", user, orgIdentifier); + try { + log.atTrace().log("sending request to make user {} an admin for org {}.", user, orgIdentifier); + HttpResponse response = boundaryPermissionClient.makeAdminForOrg(orgId, user); + log.atTrace().log("received api response status: {}", response.status()); + log.atDebug().log("Successfully made user {} an admin for org {}.", user, orgIdentifier); + successfulReassignments.put(org, orgId); + } catch (HttpClientResponseException e) { + printError("Failed to make user " + user + " an admin for organization " + orgIdentifier + "."); + log.atError().setCause(e).log("Error response from API: {}", e.getResponse().body()); + } + }); + printNormal("successfully reassigned %d orgs to user %s", successfulReassignments.size(), user); + } + + /** + * abstract class whose sole purpose is to allow the user to pass a list of orgID's and org Slugs + */ + private abstract static class Org { + } + + @EqualsAndHashCode(callSuper = true) + @Data + private static class OrgSlug extends Org { + private final String slug; + } + + @EqualsAndHashCode(callSuper = true) + @Data + private static class OrgId extends Org { + private final UUID id; + } + + private static class OrgConverter implements CommandLine.ITypeConverter { + @Override + public Org convert(String value) { + try { + return new OrgId(UUID.fromString(value)); + } catch (IllegalArgumentException e) { + if (value.matches("[^\\s/]+")) { + return new OrgSlug(value); + } else { + throw new CommandLine.TypeConversionException("'" + value + "' is not a valid organization UID or URL slug."); + } + } + } + } +} diff --git a/src/main/java/org/justserve/cli/util/JustServePrinter.java b/src/main/java/org/justserve/cli/util/JustServePrinter.java index 643431d..4c1a78f 100644 --- a/src/main/java/org/justserve/cli/util/JustServePrinter.java +++ b/src/main/java/org/justserve/cli/util/JustServePrinter.java @@ -55,13 +55,22 @@ public static void printNormal(String message) { jsPrint(message, normalStyle); } + /** + * Prints a standard message using the primary brand color (JustServe Blue). + * + * @param message The message to print. + */ + public static void printNormal(String message, Object... args) { + jsPrint(String.format(message, args), normalStyle); + } + /** * Returns a String stylized in Orange. * * @param message The message to print. */ public static String styleTitle(String message) { - return applyStyle(message, titleStyle); + return applyStyle(message, titleStyle); } /** diff --git a/src/main/resources/schema.yml b/src/main/resources/schema.yml index e6fc86b..32d6540 100644 --- a/src/main/resources/schema.yml +++ b/src/main/resources/schema.yml @@ -6,51 +6,11 @@ info: name: Jonathan Zollinger version: v0.0.5 tags: - - BoundaryPermission - DynamicRouting - Image - User - Organization paths: - /api/v1/boundaries/rep/{userId}/org/{orgId}: - put: - tags: [ BoundaryPermission ] - operationId: MakeAdminForOrg - description: Make User an admin to a given organization. returns a null response. - parameters: - - name: orgId - in: path - required: true - schema: { type: string, format: uuid } - - name: userId - in: path - required: true - schema: { type: string, format: uuid } - responses: - '200': - description: OK - content: - application/json: - schema: - type: null - /api/v1/boundaries/update: - put: - tags: - - BoundaryPermission - operationId: updateBoundary - description: Modify a user's boundaries - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/BoundaryUpdateRequest' - responses: - '200': - description: OK - content: - application/json: - schema: - type: null /api/v1/images: post: tags: [ Image ] diff --git a/src/test/groovy/org/justserve/JustServeSpec.groovy b/src/test/groovy/org/justserve/JustServeSpec.groovy index e08156a..675364c 100644 --- a/src/test/groovy/org/justserve/JustServeSpec.groovy +++ b/src/test/groovy/org/justserve/JustServeSpec.groovy @@ -4,6 +4,7 @@ import io.micronaut.context.ApplicationContext import io.micronaut.context.env.Environment import io.micronaut.http.HttpResponse import io.micronaut.http.HttpStatus +import io.micronaut.http.client.exceptions.HttpClientResponseException import io.micronaut.test.extensions.spock.annotation.MicronautTest import net.datafaker.Faker import org.justserve.client.* @@ -80,7 +81,16 @@ class JustServeSpec extends Specification { } def createUser() { - return createUser(new TestUser(new Faker(Locale.of("en-us")))) + def response + while (null == response) { + try { + // A new user is generated on each loop iteration to avoid collisions + response = createUser(noAuthUserClient, new TestUser(new Faker(Locale.of("en-us")))) + } catch (HttpClientResponseException ignored) { + // This user likely already exists, so we'll loop and try a new one. + } + } + return response } def createUser(UserClient client = noAuthUserClient, TestUser user) { @@ -92,10 +102,13 @@ class JustServeSpec extends Specification { user.zipcode, user.locale, user.country, - user.countryCode - ) + user.countryCode) } + /** + * creates a random org for testing + * @return + */ UUID createOrg() { def orgRequest = new OrganizationCreateRequest() .setContactEmail(faker.internet().emailAddress()) @@ -113,6 +126,17 @@ class JustServeSpec extends Specification { return authDynamicRoutingClient.getOrgIdFromSlug(orgRequest.url).body().id } + /** + * Creates a specified number of random organizations for testing. + * @param count The number of organizations to create. + * @return A list of UUIDs for the created organizations. + */ + List createOrgs(int count) { + return (1..count).collect { + createOrg() + } + } + /** * Creates a default organization search request for testing. diff --git a/src/test/groovy/org/justserve/client/BoundaryPermissionSpec.groovy b/src/test/groovy/org/justserve/client/BoundaryPermissionSpec.groovy index 86478d1..ad32930 100644 --- a/src/test/groovy/org/justserve/client/BoundaryPermissionSpec.groovy +++ b/src/test/groovy/org/justserve/client/BoundaryPermissionSpec.groovy @@ -1,10 +1,11 @@ package org.justserve.client import io.micronaut.http.HttpResponse +import io.micronaut.http.client.exceptions.HttpClientResponseException import org.justserve.JustServeSpec import spock.lang.Shared -import static io.micronaut.http.HttpStatus.UNAUTHORIZED +import static io.micronaut.http.HttpStatus.INTERNAL_SERVER_ERROR class BoundaryPermissionSpec extends JustServeSpec { @@ -16,9 +17,8 @@ class BoundaryPermissionSpec extends JustServeSpec { authBoundaryPermissionClient = ctx.getBean(BoundaryPermissionClient) } - def "can reassign org with no errors"() { + def "can reassign organizations #title"() { given: - UUID orgID = createOrg() UUID userID = createUser().body().id when: @@ -32,12 +32,14 @@ class BoundaryPermissionSpec extends JustServeSpec { } return } - noExceptionThrown() + def exception = thrown(HttpClientResponseException) + exception.status == expectedError where: - client | expectedError | title | _ - authBoundaryPermissionClient | null | "as an admin and the assignment takes hold" | _ - noAuthBoundaryPermissionClient | UNAUTHORIZED | "as an unauthorized user and the request fails" | _ + client | expectedError | title | orgID | _ + authBoundaryPermissionClient | null | "with a good OrgID as an admin and the request succeeds" | createOrg() | _ + noAuthBoundaryPermissionClient | INTERNAL_SERVER_ERROR | "with a bad OrgID as an admin and the request fails" | UUID.fromString(faker.internet().uuid().toString()) | _ +// noAuthBoundaryPermissionClient | UNAUTHORIZED | "as an unauthorized user and the request fails" | createOrg() | _ } diff --git a/src/test/groovy/org/justserve/command/BaseCommandSpec.groovy b/src/test/groovy/org/justserve/command/BaseCommandSpec.groovy index d0bd276..b3491c5 100644 --- a/src/test/groovy/org/justserve/command/BaseCommandSpec.groovy +++ b/src/test/groovy/org/justserve/command/BaseCommandSpec.groovy @@ -16,13 +16,15 @@ class BaseCommandSpec extends JustServeSpec { @Shared Pattern cliVersion, blankRegex, successRegex, errorRegex, tokenNotSetRegex + @Shared + String ansi def setupSpec() { def props = new Properties() new File('gradle.properties').withInputStream { stream -> props.load(stream) } - def ansi = "\\u001B\\[[;\\d]*m" + ansi = "\\u001B\\[[;\\d]*m" def staticText = """ _ _ ____ | | | | / ___| | |_ _ ___| |_| (___ ___ _ ____ _____ diff --git a/src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy b/src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy index 2211401..ce3bf4d 100644 --- a/src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy +++ b/src/test/groovy/org/justserve/command/GetTempPasswordSpec.groovy @@ -3,20 +3,20 @@ package org.justserve.command import io.micronaut.context.ApplicationContext import net.datafaker.Faker import org.justserve.TestUser -import org.justserve.client.UserClient import spock.lang.Execution +import spock.lang.Retry import spock.lang.Shared import spock.lang.Unroll import static org.spockframework.runtime.model.parallel.ExecutionMode.SAME_THREAD @Execution(SAME_THREAD) +@Retry class GetTempPasswordSpec extends BaseCommandSpec { @Shared TestUser readOnlyUser def setupSpec() { - noAuthCtx.getBean(UserClient) readOnlyUser = new TestUser(new Faker(Locale.of("en-us"))) readOnlyUser.uuid = createUser(readOnlyUser).body().getId() } diff --git a/src/test/groovy/org/justserve/command/MakeOrgAdminSpec.groovy b/src/test/groovy/org/justserve/command/MakeOrgAdminSpec.groovy new file mode 100644 index 0000000..efebed5 --- /dev/null +++ b/src/test/groovy/org/justserve/command/MakeOrgAdminSpec.groovy @@ -0,0 +1,78 @@ +package org.justserve.command + +import io.micronaut.context.ApplicationContext +import spock.lang.Execution + +import static org.spockframework.runtime.model.parallel.ExecutionMode.SAME_THREAD + +@Execution(SAME_THREAD) +class MakeOrgAdminSpec extends BaseCommandSpec { + + def "can make a user an admin to #orgCount org(s) using the #orgFlag and #userFlag flags #title"() { + given: + UUID userID = createUser().body().id + def orgs = createOrgs(orgCount).join(",") + + when: + def (outputStream, errorStream) = executeCommand(context as ApplicationContext, ["makeOrgAdmin", orgFlag, orgs, userFlag, userID] as String[]) + + then: + if (context == noAuthCtx) { + verifyAll { + errorStream.matches(tokenNotSetRegex) + outputStream.matches(blankRegex) + } + return + } + verifyAll { + (outputStream as String).contains("successfully reassigned ${orgCount} orgs to user ${userID}") + errorStream.matches(blankRegex) + } + + where: + orgFlag | orgCount | userFlag | context | title | _ + "-o" | 3 | "-u" | ctx | "as an authorized user successfully makes the changes" | _ + "--org" | 3 | "-u" | ctx | "as an authorized user successfully makes the changes" | _ + "-o" | 3 | "--user" | ctx | "as an authorized user successfully makes the changes" | _ + "-o" | 3 | "-u" | noAuthCtx | "as unauthorized user and fails prior to making any api call" | _ + "--org" | 3 | "-u" | noAuthCtx | "as unauthorized user and fails prior to making any api call" | _ + "-o" | 3 | "--user" | noAuthCtx | "as unauthorized user and fails prior to making any api call" | _ + "-o" | 1 | "-u" | noAuthCtx | "as unauthorized user and fails prior to making any api call" | _ + "--org" | 1 | "-u" | noAuthCtx | "as unauthorized user and fails prior to making any api call" | _ + "-o" | 1 | "--user" | noAuthCtx | "as unauthorized user and fails prior to making any api call" | _ + "-o" | 1 | "-u" | ctx | "as an authorized user successfully makes the changes" | _ + "--org" | 1 | "-u" | ctx | "as an authorized user successfully makes the changes" | _ + "-o" | 1 | "--user" | ctx | "as an authorized user successfully makes the changes" | _ + } + + def "can make a user an admin to #orgCount where at least one org ID does not exist on JustServe"() { + given: + UUID userID = createUser().body().id + String orgs + def fakeId = faker.internet().uuid().toString() + if (orgCount == 1) { + orgs = fakeId + } else { + orgs = createOrgs(orgCount - 1).join(",") + "," + fakeId + } + + when: + def (outputStream, errorStream) = executeCommand(context as ApplicationContext, ["makeOrgAdmin", orgFlag, orgs, userFlag, userID] as String[]) + + then: + verifyAll { + (outputStream as String).contains("successfully reassigned ${orgCount - 1 } orgs to user ${userID}") + (errorStream as String).contains("Failed to make user ${userID} an admin for organization ${fakeId}.") + } + + where: + orgFlag | orgCount | userFlag | context | title | _ + "-o" | 3 | "-u" | ctx | "as an authorized user successfully makes the changes" | _ + "--org" | 3 | "-u" | ctx | "as an authorized user successfully makes the changes" | _ + "-o" | 3 | "--user" | ctx | "as an authorized user successfully makes the changes" | _ + "-o" | 1 | "-u" | ctx | "as an authorized user successfully makes the changes" | _ + "--org" | 1 | "-u" | ctx | "as an authorized user successfully makes the changes" | _ + "-o" | 1 | "--user" | ctx | "as an authorized user successfully makes the changes" | _ + } + +} diff --git a/src/test/resources/SpockConfig.groovy b/src/test/resources/SpockConfig.groovy index 267ec1e..ee30777 100644 --- a/src/test/resources/SpockConfig.groovy +++ b/src/test/resources/SpockConfig.groovy @@ -1,5 +1,5 @@ -runner { - parallel { - enabled true - } -} \ No newline at end of file +//runner { +// parallel { +// enabled true +// } +//} \ No newline at end of file