From 75fbf450adee5a9f4565af28591119f3ccc669ee Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 15 May 2026 17:05:05 -0500 Subject: [PATCH 1/2] Add user list members endpoint (#1733) --- .../src/main/java/cwms/cda/ApiServlet.java | 2 + .../main/java/cwms/cda/api/Controllers.java | 1 + .../userlists/UserListMembersController.java | 90 +++++++++++++++++++ .../java/cwms/cda/data/dao/UserListDao.java | 80 +++++++++++++++++ .../dto/auth/userlists/UserListMember.java | 63 +++++++++++++ .../dto/auth/userlists/UserListMembers.java | 32 +++++++ 6 files changed, 268 insertions(+) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMembers.java diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index 2f838bd155..e95f82877c 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -101,6 +101,7 @@ import cwms.cda.api.auth.ApiKeyController; import cwms.cda.api.auth.users.UserProfileController; import cwms.cda.api.auth.users.UsersController; +import cwms.cda.api.auth.userlists.UserListMembersController; import cwms.cda.api.auth.users.roles.AddRoleController; import cwms.cda.api.auth.users.roles.DeleteRolesController; import cwms.cda.api.auth.users.roles.GetRolesController; @@ -645,6 +646,7 @@ private void addUserManagementHandlers() { crud("/users/{user-name}", new UsersController(metrics), adminRoles); get("/roles", new GetRolesController(metrics), adminRoles); get("/user/profile", new UserProfileController(metrics), userRoles); + get("/user/list/{user-list-id}/members", new UserListMembersController(metrics), adminRoles); post("/user/{user-name}/roles/{office-id}", new AddRoleController(metrics), adminRoles); delete("/user/{user-name}/roles/{office-id}", new DeleteRolesController(metrics), adminRoles); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java index fd9d40553b..3a9d65c268 100644 --- a/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java +++ b/cwms-data-api/src/main/java/cwms/cda/api/Controllers.java @@ -225,6 +225,7 @@ public final class Controllers { public static final String PROJECT_LIKE = "project-like"; public static final String USERNAME_LIKE = "username-like"; + public static final String USER_LIST_ID = "user-list-id"; public static final String APPLICATION_ID = "application-id"; public static final String REVOKE_EXISTING = "revoke-existing"; public static final String REVOKE_TIMEOUT = "revoke-timeout"; diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java new file mode 100644 index 0000000000..655a51884f --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListMembersController.java @@ -0,0 +1,90 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.GET_ONE; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.USER_LIST_ID; +import static cwms.cda.data.dao.JooqDao.getDslContext; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.api.errors.RequiredQueryParameterException; +import cwms.cda.data.dao.UserListDao; +import cwms.cda.data.dto.Office; +import cwms.cda.data.dto.auth.userlists.UserListMembers; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; +import org.jooq.DSLContext; + +public final class UserListMembersController implements Handler { + public static final String TAG = "User Management"; + private final MetricRegistry metrics; + + public UserListMembersController(MetricRegistry metrics) { + this.metrics = metrics; + } + + private Timer.Context markAndTime(String subject) { + return Controllers.markAndTime(metrics, getClass().getName(), subject); + } + + @OpenApi( + pathParams = { + @OpenApiParam(name = USER_LIST_ID, required = true, + description = "The identifier of the user list to retrieve members for.") + }, + queryParams = { + @OpenApiParam(name = OFFICE, required = true, + description = "The office that owns the requested user list.") + }, + responses = { + @OpenApiResponse( + status = STATUS_200, + content = { + @OpenApiContent(from = UserListMembers.class, type = Formats.JSON) + } + ) + }, + security = { + @OpenApiSecurity(name = "gets overridden allows lock icon.") + }, + description = "Retrieve the members of a user list.", + method = HttpMethod.GET, + tags = {TAG} + ) + @Override + public void handle(Context ctx) { + try (final Timer.Context ignored = markAndTime(GET_ONE)) { + String office = ctx.queryParam(OFFICE); + if (office == null || office.isBlank()) { + throw new RequiredQueryParameterException(OFFICE); + } + + office = ctx.queryParamAsClass(OFFICE, String.class) + .check(Office::validOfficeNotNull, "Invalid office provided") + .get(); + + String userListId = ctx.pathParam(USER_LIST_ID); + DSLContext dsl = getDslContext(ctx); + UserListDao dao = new UserListDao(dsl); + UserListMembers members = dao.getMembers(office, userListId); + + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, UserListMembers.class); + String result = Formats.format(contentType, members); + + ctx.result(result); + ctx.contentType(contentType.toString()); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java new file mode 100644 index 0000000000..e40619a7f4 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java @@ -0,0 +1,80 @@ +package cwms.cda.data.dao; + +import static org.jooq.impl.DSL.field; +import static org.jooq.impl.DSL.name; +import static org.jooq.impl.DSL.selectOne; +import static org.jooq.impl.DSL.table; +import static org.jooq.impl.DSL.upper; + +import cwms.cda.api.errors.NotFoundException; +import cwms.cda.data.dto.auth.userlists.UserListMember; +import cwms.cda.data.dto.auth.userlists.UserListMembers; +import java.util.List; +import java.util.Optional; +import org.jooq.Condition; +import org.jooq.DSLContext; +import org.jooq.Field; +import org.jooq.Table; + +public final class UserListDao extends Dao { + + private final Table avUserListMembers = table(name("cwms_20", "av_user_list_members")).as("ulm"); + private final Table atUserLists = table(name("cwms_20", "at_user_lists")).as("ul"); + private final Table cwmsOffice = table(name("cwms_20", "cwms_office")).as("co"); + + public UserListDao(DSLContext dsl) { + super(dsl); + } + + @Override + public Optional getByUniqueName(String uniqueName, String office) { + return Optional.empty(); + } + + public UserListMembers getMembers(String officeId, String userListId) { + if (!userListExists(officeId, userListId)) { + throw new NotFoundException("User list not found: " + officeId + "/" + userListId); + } + + Field viewOfficeId = field(name(avUserListMembers.getName(), "office_id"), String.class); + Field viewUserListId = field(name(avUserListMembers.getName(), "user_list_id"), String.class); + Field viewUserId = field(name(avUserListMembers.getName(), "user_id"), String.class); + Field viewFullName = field(name(avUserListMembers.getName(), "full_name"), String.class); + Field viewEmail = field(name(avUserListMembers.getName(), "email"), String.class); + + List members = dsl.select(viewOfficeId, viewUserListId, viewUserId, viewFullName, + viewEmail) + .from(avUserListMembers) + .where(ignoreCaseEq(viewOfficeId, officeId)) + .and(ignoreCaseEq(viewUserListId, userListId)) + .orderBy(viewFullName.asc().nullsLast(), viewUserId.asc()) + .fetch(record -> new UserListMember( + record.get(viewOfficeId), + record.get(viewUserListId), + record.get(viewUserId), + record.get(viewFullName), + record.get(viewEmail) + )); + + return new UserListMembers(members); + } + + private boolean userListExists(String officeId, String userListId) { + Field listOfficeCode = field(name(atUserLists.getName(), "db_office_code"), Number.class); + Field listUserListId = field(name(atUserLists.getName(), "user_list_id"), String.class); + Field officeCode = field(name(cwmsOffice.getName(), "office_code"), Number.class); + Field officeName = field(name(cwmsOffice.getName(), "office_id"), String.class); + + return dsl.fetchExists( + selectOne() + .from(atUserLists) + .join(cwmsOffice).on(listOfficeCode.eq(officeCode)) + .where(ignoreCaseEq(officeName, officeId)) + .and(ignoreCaseEq(listUserListId, userListId)) + ); + } + + private static Condition ignoreCaseEq(Field field, String value) { + return upper(field).eq(value.toUpperCase()); + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java new file mode 100644 index 0000000000..f0e2da8675 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMember.java @@ -0,0 +1,63 @@ +package cwms.cda.data.dto.auth.userlists; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, + aliases = {Formats.DEFAULT, Formats.JSON}) +public final class UserListMember extends CwmsDTOBase { + + @JsonProperty(required = true) + @Schema(description = "The owning CWMS office identifier for the user list.") + private final String officeId; + + @JsonProperty(required = true) + @Schema(description = "The identifier of the user list.") + private final String userListId; + + @JsonProperty(required = true) + @Schema(description = "The user identifier for the member.") + private final String userId; + + @Schema(description = "The user's display name.") + private final String fullName; + + @Schema(description = "The user's email address.") + private final String email; + + public UserListMember(String officeId, String userListId, String userId, String fullName, + String email) { + this.officeId = officeId; + this.userListId = userListId; + this.userId = userId; + this.fullName = fullName; + this.email = email; + } + + public String getOfficeId() { + return officeId; + } + + public String getUserListId() { + return userListId; + } + + public String getUserId() { + return userId; + } + + public String getFullName() { + return fullName; + } + + public String getEmail() { + return email; + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMembers.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMembers.java new file mode 100644 index 0000000000..a0525442d8 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserListMembers.java @@ -0,0 +1,32 @@ +package cwms.cda.data.dto.auth.userlists; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Collections; +import java.util.List; + +@JsonRootName("user-list-members") +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, + aliases = {Formats.DEFAULT, Formats.JSON}) +public final class UserListMembers extends CwmsDTOBase { + + @JsonProperty(required = true) + @Schema(description = "Members in the requested user list.") + private final List members; + + public UserListMembers(List members) { + this.members = List.copyOf(members); + } + + public List getMembers() { + return Collections.unmodifiableList(members); + } +} From 257c9e02b8f2be73d5b0f5edc434e680f7ff7e21 Mon Sep 17 00:00:00 2001 From: Charles Graham Date: Tue, 2 Jun 2026 16:37:35 -0500 Subject: [PATCH 2/2] Add user list metadata endpoint --- .../src/main/java/cwms/cda/ApiServlet.java | 2 + .../auth/userlists/UserListController.java | 93 +++++++++ .../java/cwms/cda/data/dao/UserListDao.java | 51 +++-- .../cda/data/dto/auth/userlists/UserList.java | 72 +++++++ .../api/users/UserListControllerTestIT.java | 184 ++++++++++++++++++ 5 files changed, 390 insertions(+), 12 deletions(-) create mode 100644 cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java create mode 100644 cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java diff --git a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java index e95f82877c..43d05cf300 100644 --- a/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java +++ b/cwms-data-api/src/main/java/cwms/cda/ApiServlet.java @@ -101,6 +101,7 @@ import cwms.cda.api.auth.ApiKeyController; import cwms.cda.api.auth.users.UserProfileController; import cwms.cda.api.auth.users.UsersController; +import cwms.cda.api.auth.userlists.UserListController; import cwms.cda.api.auth.userlists.UserListMembersController; import cwms.cda.api.auth.users.roles.AddRoleController; import cwms.cda.api.auth.users.roles.DeleteRolesController; @@ -646,6 +647,7 @@ private void addUserManagementHandlers() { crud("/users/{user-name}", new UsersController(metrics), adminRoles); get("/roles", new GetRolesController(metrics), adminRoles); get("/user/profile", new UserProfileController(metrics), userRoles); + get("/user/list/{user-list-id}", new UserListController(metrics), adminRoles); get("/user/list/{user-list-id}/members", new UserListMembersController(metrics), adminRoles); post("/user/{user-name}/roles/{office-id}", new AddRoleController(metrics), adminRoles); delete("/user/{user-name}/roles/{office-id}", new DeleteRolesController(metrics), adminRoles); diff --git a/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java new file mode 100644 index 0000000000..ef4dfbfea4 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/api/auth/userlists/UserListController.java @@ -0,0 +1,93 @@ +package cwms.cda.api.auth.userlists; + +import static cwms.cda.api.Controllers.GET_ONE; +import static cwms.cda.api.Controllers.OFFICE; +import static cwms.cda.api.Controllers.STATUS_200; +import static cwms.cda.api.Controllers.USER_LIST_ID; +import static cwms.cda.data.dao.JooqDao.getDslContext; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import cwms.cda.api.Controllers; +import cwms.cda.api.errors.NotFoundException; +import cwms.cda.api.errors.RequiredQueryParameterException; +import cwms.cda.data.dao.UserListDao; +import cwms.cda.data.dto.Office; +import cwms.cda.data.dto.auth.userlists.UserList; +import cwms.cda.formatters.ContentType; +import cwms.cda.formatters.Formats; +import io.javalin.core.util.Header; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.plugin.openapi.annotations.HttpMethod; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiContent; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import io.javalin.plugin.openapi.annotations.OpenApiResponse; +import io.javalin.plugin.openapi.annotations.OpenApiSecurity; +import org.jooq.DSLContext; + +public final class UserListController implements Handler { + public static final String TAG = "User Management"; + private final MetricRegistry metrics; + + public UserListController(MetricRegistry metrics) { + this.metrics = metrics; + } + + private Timer.Context markAndTime(String subject) { + return Controllers.markAndTime(metrics, getClass().getName(), subject); + } + + @OpenApi( + pathParams = { + @OpenApiParam(name = USER_LIST_ID, required = true, + description = "The identifier of the user list to retrieve.") + }, + queryParams = { + @OpenApiParam(name = OFFICE, required = true, + description = "The office that owns the requested user list.") + }, + responses = { + @OpenApiResponse( + status = STATUS_200, + content = { + @OpenApiContent(from = UserList.class, type = Formats.JSON) + } + ) + }, + security = { + @OpenApiSecurity(name = "gets overridden allows lock icon.") + }, + description = "Retrieve user list metadata.", + method = HttpMethod.GET, + tags = {TAG} + ) + @Override + public void handle(Context ctx) { + try (final Timer.Context ignored = markAndTime(GET_ONE)) { + String office = ctx.queryParam(OFFICE); + if (office == null || office.isBlank()) { + throw new RequiredQueryParameterException(OFFICE); + } + + final String officeId = ctx.queryParamAsClass(OFFICE, String.class) + .check(Office::validOfficeNotNull, "Invalid office provided") + .get(); + + String userListId = ctx.pathParam(USER_LIST_ID); + DSLContext dsl = getDslContext(ctx); + UserListDao dao = new UserListDao(dsl); + UserList userList = dao.getUserList(officeId, userListId) + .orElseThrow(() -> new NotFoundException("User list not found: " + + officeId + "/" + userListId)); + + String formatHeader = ctx.header(Header.ACCEPT); + ContentType contentType = Formats.parseHeader(formatHeader, UserList.class); + String result = Formats.format(contentType, userList); + + ctx.result(result); + ctx.contentType(contentType.toString()); + } + } +} diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java index e40619a7f4..dc49d6dd7c 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/UserListDao.java @@ -7,8 +7,10 @@ import static org.jooq.impl.DSL.upper; import cwms.cda.api.errors.NotFoundException; +import cwms.cda.data.dto.auth.userlists.UserList; import cwms.cda.data.dto.auth.userlists.UserListMember; import cwms.cda.data.dto.auth.userlists.UserListMembers; +import java.sql.Timestamp; import java.util.List; import java.util.Optional; import org.jooq.Condition; @@ -18,9 +20,9 @@ public final class UserListDao extends Dao { - private final Table avUserListMembers = table(name("cwms_20", "av_user_list_members")).as("ulm"); - private final Table atUserLists = table(name("cwms_20", "at_user_lists")).as("ul"); - private final Table cwmsOffice = table(name("cwms_20", "cwms_office")).as("co"); + private final Table avUserListMembers = table(name("CWMS_20", "AV_USER_LIST_MEMBERS")).as("ulm"); + private final Table atUserLists = table(name("CWMS_20", "AT_USER_LISTS")).as("ul"); + private final Table cwmsOffice = table(name("CWMS_20", "CWMS_OFFICE")).as("co"); public UserListDao(DSLContext dsl) { super(dsl); @@ -31,16 +33,41 @@ public Optional getByUniqueName(String uniqueName, String office return Optional.empty(); } + public Optional getUserList(String officeId, String userListId) { + Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); + Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); + Field listDescription = field(name(atUserLists.getName(), "USER_LIST_DESC"), String.class); + Field listOwner = field(name(atUserLists.getName(), "OWNED_BY_USERID"), String.class); + Field listCreatedAt = field(name(atUserLists.getName(), "CREATED_AT"), Timestamp.class); + Field listUpdatedAt = field(name(atUserLists.getName(), "UPDATED_AT"), Timestamp.class); + Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); + Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); + + return dsl.select(officeName, listUserListId, listDescription, listOwner, listCreatedAt, listUpdatedAt) + .from(atUserLists) + .join(cwmsOffice).on(listOfficeCode.eq(officeCode)) + .where(ignoreCaseEq(officeName, officeId)) + .and(ignoreCaseEq(listUserListId, userListId)) + .fetchOptional(record -> new UserList( + record.get(officeName), + record.get(listUserListId), + record.get(listDescription), + record.get(listOwner), + record.get(listCreatedAt).toInstant(), + Optional.ofNullable(record.get(listUpdatedAt)).map(Timestamp::toInstant).orElse(null) + )); + } + public UserListMembers getMembers(String officeId, String userListId) { if (!userListExists(officeId, userListId)) { throw new NotFoundException("User list not found: " + officeId + "/" + userListId); } - Field viewOfficeId = field(name(avUserListMembers.getName(), "office_id"), String.class); - Field viewUserListId = field(name(avUserListMembers.getName(), "user_list_id"), String.class); - Field viewUserId = field(name(avUserListMembers.getName(), "user_id"), String.class); - Field viewFullName = field(name(avUserListMembers.getName(), "full_name"), String.class); - Field viewEmail = field(name(avUserListMembers.getName(), "email"), String.class); + Field viewOfficeId = field(name(avUserListMembers.getName(), "OFFICE_ID"), String.class); + Field viewUserListId = field(name(avUserListMembers.getName(), "USER_LIST_ID"), String.class); + Field viewUserId = field(name(avUserListMembers.getName(), "USER_ID"), String.class); + Field viewFullName = field(name(avUserListMembers.getName(), "FULL_NAME"), String.class); + Field viewEmail = field(name(avUserListMembers.getName(), "EMAIL"), String.class); List members = dsl.select(viewOfficeId, viewUserListId, viewUserId, viewFullName, viewEmail) @@ -60,10 +87,10 @@ public UserListMembers getMembers(String officeId, String userListId) { } private boolean userListExists(String officeId, String userListId) { - Field listOfficeCode = field(name(atUserLists.getName(), "db_office_code"), Number.class); - Field listUserListId = field(name(atUserLists.getName(), "user_list_id"), String.class); - Field officeCode = field(name(cwmsOffice.getName(), "office_code"), Number.class); - Field officeName = field(name(cwmsOffice.getName(), "office_id"), String.class); + Field listOfficeCode = field(name(atUserLists.getName(), "DB_OFFICE_CODE"), Long.class); + Field listUserListId = field(name(atUserLists.getName(), "USER_LIST_ID"), String.class); + Field officeCode = field(name(cwmsOffice.getName(), "OFFICE_CODE"), Long.class); + Field officeName = field(name(cwmsOffice.getName(), "OFFICE_ID"), String.class); return dsl.fetchExists( selectOne() diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java new file mode 100644 index 0000000000..177522a571 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dto/auth/userlists/UserList.java @@ -0,0 +1,72 @@ +package cwms.cda.data.dto.auth.userlists; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import cwms.cda.data.dto.CwmsDTOBase; +import cwms.cda.formatters.Formats; +import cwms.cda.formatters.annotations.FormattableWith; +import cwms.cda.formatters.json.JsonV1; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; + +@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) +@FormattableWith(contentType = Formats.JSONV1, formatter = JsonV1.class, + aliases = {Formats.DEFAULT, Formats.JSON}) +public final class UserList extends CwmsDTOBase { + + @JsonProperty(required = true) + @Schema(description = "The owning CWMS office identifier for the user list.") + private final String officeId; + + @JsonProperty(required = true) + @Schema(description = "The identifier of the user list.") + private final String userListId; + + @Schema(description = "The user list description.") + private final String description; + + @Schema(description = "The user id of the list owner.") + private final String ownedByUserId; + + @JsonProperty(required = true) + @Schema(description = "The time the user list was created.") + private final Instant createdAt; + + @Schema(description = "The time the user list was last updated.") + private final Instant updatedAt; + + public UserList(String officeId, String userListId, String description, String ownedByUserId, + Instant createdAt, Instant updatedAt) { + this.officeId = officeId; + this.userListId = userListId; + this.description = description; + this.ownedByUserId = ownedByUserId; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public String getOfficeId() { + return officeId; + } + + public String getUserListId() { + return userListId; + } + + public String getDescription() { + return description; + } + + public String getOwnedByUserId() { + return ownedByUserId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java new file mode 100644 index 0000000000..00839a2694 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/users/UserListControllerTestIT.java @@ -0,0 +1,184 @@ +package cwms.cda.api.users; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +import cwms.cda.api.DataApiTestIT; +import fixtures.CwmsDataApiSetupCallback; +import fixtures.KeyCloakExtension; +import fixtures.TestAccounts; +import fixtures.users.UserSpecSource; +import fixtures.users.annotation.AuthType; +import io.javalin.http.HttpCode; +import io.restassured.filter.log.LogDetail; +import io.restassured.specification.RequestSpecification; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import mil.army.usace.hec.test.database.CwmsDatabaseContainer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ArgumentsSource; + +@Tag("integration") +@ExtendWith(KeyCloakExtension.class) +public final class UserListControllerTestIT extends DataApiTestIT { + private static final String OFFICE = "SPK"; + private static final String USER_LIST_ID = "USER_LIST_CONTROLLER_TEST"; + private static final String USER_LIST_DESC = "Integration test user list"; + + @BeforeAll + static void ensureUserListSchema() throws SQLException { + CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); + db.connection(c -> { + try { + executeIgnoreObjectExists(c, + "CREATE TABLE AT_USER_LISTS (" + + "DB_OFFICE_CODE NUMBER NOT NULL, " + + "USER_LIST_ID VARCHAR2(128) NOT NULL, " + + "USER_LIST_DESC VARCHAR2(1024), " + + "OWNED_BY_USERID VARCHAR2(128), " + + "CREATED_AT TIMESTAMP DEFAULT current_timestamp NOT NULL, " + + "UPDATED_AT TIMESTAMP)"); + executeIgnoreObjectExists(c, + "CREATE UNIQUE INDEX AT_USER_LISTS_PK ON AT_USER_LISTS (USER_LIST_ID)"); + executeIgnoreObjectExists(c, + "ALTER TABLE AT_USER_LISTS ADD CONSTRAINT AT_USER_LISTS_PK " + + "PRIMARY KEY (USER_LIST_ID) USING INDEX AT_USER_LISTS_PK"); + executeIgnoreObjectExists(c, + "ALTER TABLE AT_USER_LISTS ADD CONSTRAINT AT_USER_LISTS_FK1 " + + "FOREIGN KEY (DB_OFFICE_CODE) REFERENCES CWMS_OFFICE (OFFICE_CODE)"); + executeIgnoreObjectExists(c, + "CREATE OR REPLACE TRIGGER AT_USER_LISTS_TRIG " + + "BEFORE INSERT OR UPDATE ON AT_USER_LISTS " + + "REFERENCING NEW AS new OLD AS old FOR EACH ROW BEGIN " + + ":new.user_list_id := UPPER(:new.user_list_id); " + + ":new.owned_by_userid := UPPER(:new.owned_by_userid); " + + ":new.updated_at := current_timestamp; END;"); + executeIgnoreObjectExists(c, + "CREATE TABLE AT_USER_LIST_MEMBERS (" + + "USER_LIST_ID VARCHAR2(128) NOT NULL, " + + "USERID VARCHAR2(128) NOT NULL, " + + "ADD_DATE TIMESTAMP DEFAULT current_timestamp NOT NULL, " + + "ADDED_BY_USERID VARCHAR2(128))"); + executeIgnoreObjectExists(c, + "CREATE UNIQUE INDEX AT_USER_LIST_MEMBERS_PK " + + "ON AT_USER_LIST_MEMBERS (USER_LIST_ID, USERID)"); + executeIgnoreObjectExists(c, + "ALTER TABLE AT_USER_LIST_MEMBERS ADD CONSTRAINT AT_USER_LIST_MEMBERS_PK " + + "PRIMARY KEY (USER_LIST_ID, USERID) USING INDEX AT_USER_LIST_MEMBERS_PK"); + executeIgnoreObjectExists(c, + "ALTER TABLE AT_USER_LIST_MEMBERS ADD CONSTRAINT AT_USER_LIST_MEMBERS_FK1 " + + "FOREIGN KEY (USER_LIST_ID) REFERENCES AT_USER_LISTS (USER_LIST_ID)"); + executeIgnoreObjectExists(c, + "CREATE OR REPLACE VIEW AV_USER_LIST_MEMBERS (OFFICE_ID, DB_OFFICE_CODE, " + + "USER_LIST_ID, USER_LIST_DESC, OWNED_BY_USERID, USER_ID, FULL_NAME, " + + "EMAIL, OFFICE_SYMBOL, MEMBER_OFFICE_ID, ADD_DATE, ADDED_BY_USERID) AS " + + "SELECT o.office_id, l.db_office_code, l.user_list_id, l.user_list_desc, " + + "l.owned_by_userid, u.user_id, u.full_name, u.email, u.office_symbol, " + + "u.office_id AS member_office_id, m.add_date, m.added_by_userid " + + "FROM at_user_lists l " + + "JOIN cwms_office o ON o.office_code = l.db_office_code " + + "JOIN at_user_list_members m ON m.user_list_id = l.user_list_id " + + "JOIN av_cwms_user u ON u.user_id = m.userid"); + executeIgnoreInsufficientPrivilege(c, "GRANT SELECT ON AT_USER_LISTS TO CWMS_USER"); + executeIgnoreInsufficientPrivilege(c, "GRANT SELECT ON AT_USER_LIST_MEMBERS TO CWMS_USER"); + executeIgnoreInsufficientPrivilege(c, "GRANT SELECT ON AV_USER_LIST_MEMBERS TO CWMS_USER"); + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + }, "cwms_20"); + } + + @BeforeEach + void createUserList() throws SQLException { + CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); + db.connection(c -> { + try { + try (PreparedStatement deleteMembers = c.prepareStatement( + "DELETE FROM AT_USER_LIST_MEMBERS WHERE USER_LIST_ID = ?"); + PreparedStatement deleteList = c.prepareStatement( + "DELETE FROM AT_USER_LISTS WHERE USER_LIST_ID = ?"); + PreparedStatement insertList = c.prepareStatement( + "INSERT INTO AT_USER_LISTS (DB_OFFICE_CODE, USER_LIST_ID, USER_LIST_DESC) " + + "SELECT OFFICE_CODE, ?, ? FROM CWMS_OFFICE WHERE OFFICE_ID = ?")) { + deleteMembers.setString(1, USER_LIST_ID); + deleteMembers.executeUpdate(); + deleteList.setString(1, USER_LIST_ID); + deleteList.executeUpdate(); + insertList.setString(1, USER_LIST_ID); + insertList.setString(2, USER_LIST_DESC); + insertList.setString(3, OFFICE); + insertList.executeUpdate(); + } + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + }, "cwms_20"); + } + + @AfterEach + void deleteUserList() throws SQLException { + CwmsDatabaseContainer db = CwmsDataApiSetupCallback.getDatabaseLink(); + db.connection(c -> { + try { + try (PreparedStatement deleteMembers = c.prepareStatement( + "DELETE FROM AT_USER_LIST_MEMBERS WHERE USER_LIST_ID = ?"); + PreparedStatement deleteList = c.prepareStatement( + "DELETE FROM AT_USER_LISTS WHERE USER_LIST_ID = ?")) { + deleteMembers.setString(1, USER_LIST_ID); + deleteMembers.executeUpdate(); + deleteList.setString(1, USER_LIST_ID); + deleteList.executeUpdate(); + } + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + }, "cwms_20"); + } + + @ParameterizedTest + @ArgumentsSource(UserSpecSource.class) + @AuthType(user = TestAccounts.KeyUser.SPK_NORMAL2) + void test_get_user_list(String authType, TestAccounts.KeyUser theUser, RequestSpecification authSpec) { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .spec(authSpec) + .queryParam("office", OFFICE) + .when() + .get("/user/list/{user-list-id}", USER_LIST_ID) + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .statusCode(is(HttpCode.OK.getStatus())) + .body("office-id", equalTo(OFFICE)) + .body("user-list-id", equalTo(USER_LIST_ID)) + .body("description", equalTo(USER_LIST_DESC)); + } + + private static void executeIgnoreObjectExists(java.sql.Connection c, String sql) throws SQLException { + try (PreparedStatement stmt = c.prepareStatement(sql)) { + stmt.execute(); + } catch (SQLException ex) { + String message = ex.getMessage(); + if (message == null || !(message.contains("ORA-00955") || message.contains("ORA-02260") + || message.contains("ORA-02261") || message.contains("ORA-02275"))) { + throw ex; + } + } + } + + private static void executeIgnoreInsufficientPrivilege(java.sql.Connection c, String sql) throws SQLException { + try (PreparedStatement stmt = c.prepareStatement(sql)) { + stmt.execute(); + } catch (SQLException ex) { + String message = ex.getMessage(); + if (message == null || !message.contains("ORA-01031")) { + throw ex; + } + } + } +}