diff --git a/README.md b/README.md index 5cbcca9..e539948 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ String html = lettermint.messages().html("message-id"); | `routes()` | `list(projectId)`, `iterate(projectId)`, `create(projectId, …)`, `retrieve`, `update`, `delete`, `verifyInboundDomain` | | `stats()` | `retrieve` | | `suppressions()` | `list`, `iterate`, `create`, `delete` | -| `team()` | `retrieve`, `update`, `usage`, `roles` | +| `team()` | `retrieve`, `update`, `usage`, `roles`, `retrieveRole`, `createRole`, `updateRole`, `deleteRole`, `duplicateRole` | | `team().members()` | `list`, `iterate`, `retrieve`, `updateAssignment` | | `team().invites()` | `list`, `iterate`, `retrieve`, `create`, `update`, `revoke` | | `webhooks()` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerateSecret` | @@ -295,6 +295,18 @@ TeamInviteData invite = lettermint.team().invites().create(new CreateTeamInviteD lettermint.team().invites().revoke(invite.id()); ``` +`team().createRole(...)` creates a custom role; it needs a plan with custom roles, and the permissions must include the permissions they depend on. Built-in roles can't be updated or deleted. While a role is still assigned to members, invites or SCIM groups, `deleteRole` needs a replacement role that receives those assignments: + +```java +TeamRoleData role = lettermint.team().createRole(CreateTeamRoleData.builder() + .name("Support") + .description("Answers customer tickets") + .permissions(List.of(RbacPermission.MEMBERS_READ, RbacPermission.SUPPORT_MANAGE)) + .build()); +lettermint.team().duplicateRole(role.id(), DuplicateTeamRoleData.builder().name("Support (EU)").build()); +lettermint.team().deleteRole(role.id(), new DeleteTeamRoleQuery(replacementRoleId)); +``` + Request and response types are immutable records in `co.lettermint.types`. Build requests with their builder (`UpdateRouteData.builder().name("Main").build()`) or, for small ones, the constructor (`new StoreDomainData("acme.com")`). In update requests, a field that is optional and nullable is an `OptionalNullable`: leave it unset to keep the current value, or pass `null` to the builder to clear it: ```java diff --git a/src/main/java/co/lettermint/Team.java b/src/main/java/co/lettermint/Team.java index d910da9..cfb3b2d 100644 --- a/src/main/java/co/lettermint/Team.java +++ b/src/main/java/co/lettermint/Team.java @@ -1,12 +1,18 @@ package co.lettermint; +import co.lettermint.types.CreateTeamRoleData; +import co.lettermint.types.DeleteTeamRoleQuery; +import co.lettermint.types.DuplicateTeamRoleData; import co.lettermint.types.GetTeamQuery; +import co.lettermint.types.MessageResponse; import co.lettermint.types.Operations; import co.lettermint.types.TeamData; import co.lettermint.types.TeamMutationResponse; +import co.lettermint.types.TeamRoleData; import co.lettermint.types.TeamRoleListResponse; import co.lettermint.types.TeamUsageDetailData; import co.lettermint.types.UpdateTeamData; +import co.lettermint.types.UpdateTeamRoleData; /** The team of the token. Needs the team token. Thread-safe. */ public final class Team { @@ -123,6 +129,134 @@ public TeamRoleListResponse roles(RequestOptions options) { return transport.call(Operations.LIST_TEAM_ROLES, Transport.call("team.roles").options(options)); } + /** + * Retrieves a team role. + * + * @param roleId the role id + * @return the response + */ + public TeamRoleData retrieveRole(String roleId) { + return retrieveRole(roleId, null); + } + + /** + * Retrieves a team role. + * + * @param roleId the role id + * @param options per-call options, or null + * @return the response + */ + public TeamRoleData retrieveRole(String roleId, RequestOptions options) { + return transport.call(Operations.GET_TEAM_ROLE, Transport.call("team.retrieveRole").path(roleId).options(options)); + } + + /** + * Creates a custom role. Needs a plan with custom roles; the permissions must include the + * permissions they depend on, and role names are unique within the team. + * + * @param body the request body + * @return the response + */ + public TeamRoleData createRole(CreateTeamRoleData body) { + return createRole(body, null); + } + + /** + * Creates a custom role. Needs a plan with custom roles; the permissions must include the + * permissions they depend on, and role names are unique within the team. + * + * @param body the request body + * @param options per-call options, or null + * @return the response + */ + public TeamRoleData createRole(CreateTeamRoleData body, RequestOptions options) { + return transport.call(Operations.CREATE_TEAM_ROLE, Transport.call("team.createRole").body(body).options(options)); + } + + /** + * Replaces a custom role's name, description and permissions. Built-in roles can't be changed. + * + * @param roleId the role id + * @param body the request body + * @return the response + */ + public TeamRoleData updateRole(String roleId, UpdateTeamRoleData body) { + return updateRole(roleId, body, null); + } + + /** + * Replaces a custom role's name, description and permissions. Built-in roles can't be changed. + * + * @param roleId the role id + * @param body the request body + * @param options per-call options, or null + * @return the response + */ + public TeamRoleData updateRole(String roleId, UpdateTeamRoleData body, RequestOptions options) { + return transport.call(Operations.UPDATE_TEAM_ROLE, Transport.call("team.updateRole").path(roleId).body(body).options(options)); + } + + /** + * Deletes a custom role. Built-in roles can't be deleted. While the role is still assigned to + * members, invites or SCIM groups, pass {@code replacementRoleId} to move those assignments. + * + * @param roleId the role id + * @return the response + */ + public MessageResponse deleteRole(String roleId) { + return deleteRole(roleId, null, null); + } + + /** + * Deletes a custom role. Built-in roles can't be deleted. While the role is still assigned to + * members, invites or SCIM groups, pass {@code replacementRoleId} to move those assignments. + * + * @param roleId the role id + * @param query the query parameters, or null + * @return the response + */ + public MessageResponse deleteRole(String roleId, DeleteTeamRoleQuery query) { + return deleteRole(roleId, query, null); + } + + /** + * Deletes a custom role. Built-in roles can't be deleted. While the role is still assigned to + * members, invites or SCIM groups, pass {@code replacementRoleId} to move those assignments. + * + * @param roleId the role id + * @param query the query parameters, or null + * @param options per-call options, or null + * @return the response + */ + public MessageResponse deleteRole(String roleId, DeleteTeamRoleQuery query, RequestOptions options) { + return transport.call(Operations.DELETE_TEAM_ROLE, Transport.call("team.deleteRole").path(roleId).query(query).options(options)); + } + + /** + * Copies a role under a new name. Without a description, the copy keeps the source role's + * description. The Owner role can't be duplicated. + * + * @param roleId the role id + * @param body the request body + * @return the response + */ + public TeamRoleData duplicateRole(String roleId, DuplicateTeamRoleData body) { + return duplicateRole(roleId, body, null); + } + + /** + * Copies a role under a new name. Without a description, the copy keeps the source role's + * description. The Owner role can't be duplicated. + * + * @param roleId the role id + * @param body the request body + * @param options per-call options, or null + * @return the response + */ + public TeamRoleData duplicateRole(String roleId, DuplicateTeamRoleData body, RequestOptions options) { + return transport.call(Operations.DUPLICATE_TEAM_ROLE, Transport.call("team.duplicateRole").path(roleId).body(body).options(options)); + } + @Override public String toString() { return "Team{}"; diff --git a/src/test/java/co/lettermint/TeamApiTest.java b/src/test/java/co/lettermint/TeamApiTest.java index ce66651..74448e8 100644 --- a/src/test/java/co/lettermint/TeamApiTest.java +++ b/src/test/java/co/lettermint/TeamApiTest.java @@ -17,8 +17,11 @@ import co.lettermint.types.AnalyticsQuery; import co.lettermint.types.CreateTeamInviteData; import co.lettermint.types.CreateTeamInviteDataProjectAccess; +import co.lettermint.types.CreateTeamRoleData; +import co.lettermint.types.DeleteTeamRoleQuery; import co.lettermint.types.CursorPage; import co.lettermint.types.DomainListData; +import co.lettermint.types.DuplicateTeamRoleData; import co.lettermint.types.GetStatsQuery; import co.lettermint.types.ListDomainsQuery; import co.lettermint.types.ListTeamInvitesQuery; @@ -28,6 +31,7 @@ import co.lettermint.types.Operations; import co.lettermint.types.PendingSendMailResponse; import co.lettermint.types.ProjectAccessScope; +import co.lettermint.types.RbacPermission; import co.lettermint.types.ReportForwardingRequest; import co.lettermint.types.RescheduleMessageRequest; import co.lettermint.types.ResendMessageRequest; @@ -41,6 +45,7 @@ import co.lettermint.types.SuppressionCreateReason; import co.lettermint.types.SuppressionCreateScope; import co.lettermint.types.TeamInviteData; +import co.lettermint.types.TeamRoleData; import co.lettermint.types.UpdateDomainProjectsData; import co.lettermint.types.UpdateProjectData; import co.lettermint.types.UpdateRouteData; @@ -49,6 +54,7 @@ import co.lettermint.types.UpdateTeamInviteDataProjectAccess; import co.lettermint.types.UpdateTeamMemberAssignmentData; import co.lettermint.types.UpdateTeamMemberAssignmentDataProjectAccess; +import co.lettermint.types.UpdateTeamRoleData; import co.lettermint.types.UpdateWebhookData; import co.lettermint.types.VerifyReportForwardingRequest; import co.lettermint.types.WebhookDeliveryData; @@ -71,11 +77,6 @@ class TeamApiTest { * PRs removes its operations from this list, and the last one removes the list. */ private static final Set PENDING = Set.of( - "GET /team/roles/{roleId}", - "POST /team/roles", - "PUT /team/roles/{roleId}", - "DELETE /team/roles/{roleId}", - "POST /team/roles/{roleId}/duplicate", "DELETE /team/members/{userId}"); private FakeServer server; @@ -194,6 +195,11 @@ void everyOperationIsReachable() { () -> client.team().update(UpdateTeamData.builder().name("Acme").build()), () -> client.team().usage(), () -> client.team().roles(), + () -> client.team().retrieveRole("role1"), + () -> client.team().createRole(CreateTeamRoleData.builder().name("Support").permissions(List.of(RbacPermission.MEMBERS_READ)).build()), + () -> client.team().updateRole("role1", UpdateTeamRoleData.builder().name("Support").permissions(List.of()).build()), + () -> client.team().deleteRole("role1"), + () -> client.team().duplicateRole("role1", DuplicateTeamRoleData.builder().name("Support 2").build()), () -> client.team().members().list(), () -> client.team().members().iterate().forEach(item -> { }), () -> client.team().members().retrieve("u1"), @@ -467,6 +473,101 @@ void teamInviteErrorsMapToApiExceptions() { assertThrows(NotFoundException.class, () -> client.team().invites().revoke("i1")); } + private static final String ROLE = "{\"id\":\"role 1\",\"name\":\"Support\",\"description\":\"Answers tickets\",\"system_key\":null," + + "\"permissions\":[\"members:read\",\"support:manage\"],\"assignable\":true}"; + + @Test + void retrievesCreatesAndUpdatesTeamRoles() { + server.enqueue(FakeServer.Response.json(200, ROLE)); + TeamRoleData role = client.team().retrieveRole("role 1"); + assertTeamRequest("GET", "/v1/team/roles/role%201"); + assertEquals("role 1", role.id()); + assertEquals("Support", role.name()); + assertEquals("Answers tickets", role.description()); + assertNull(role.systemKey()); + assertEquals(List.of(RbacPermission.MEMBERS_READ, RbacPermission.SUPPORT_MANAGE), role.permissions()); + assertTrue(role.assignable()); + + server.enqueue(FakeServer.Response.json(200, "{\"id\":\"role1\",\"name\":\"Admin\",\"description\":null,\"system_key\":\"admin\",\"permissions\":[],\"assignable\":true}")); + assertNull(client.team().retrieveRole("role1").description()); + server.enqueue(FakeServer.Response.json(200, "{\"id\":\"role1\",\"name\":\"Admin\"}")); + assertNull(client.team().retrieveRole("role1").description()); + + server.enqueue(FakeServer.Response.json(201, ROLE)); + TeamRoleData created = client.team().createRole(CreateTeamRoleData.builder().name("Support").description("Answers tickets") + .permissions(List.of(RbacPermission.MEMBERS_READ, RbacPermission.SUPPORT_MANAGE)).build()); + assertTeamRequest("POST", "/v1/team/roles"); + assertEquals("{\"name\":\"Support\",\"description\":\"Answers tickets\",\"permissions\":[\"members:read\",\"support:manage\"]}", server.last().body()); + assertEquals("Answers tickets", created.description()); + + server.enqueue(FakeServer.Response.json(201, ROLE)); + client.team().createRole(CreateTeamRoleData.builder().name("Support").permissions(List.of(RbacPermission.MEMBERS_READ)).build(), + RequestOptions.timeout(Duration.ofSeconds(5))); + assertEquals("{\"name\":\"Support\",\"permissions\":[\"members:read\"]}", server.last().body()); + + server.enqueue(FakeServer.Response.json(200, ROLE)); + TeamRoleData updated = client.team().updateRole("role 1", UpdateTeamRoleData.builder().name("Support").description(null) + .permissions(List.of(RbacPermission.MEMBERS_READ)).build()); + assertTeamRequest("PUT", "/v1/team/roles/role%201"); + assertEquals("{\"name\":\"Support\",\"description\":null,\"permissions\":[\"members:read\"]}", server.last().body()); + assertEquals("Support", updated.name()); + } + + @Test + void deletesAndDuplicatesTeamRoles() { + server.enqueue(FakeServer.Response.json(200, "{\"message\":\"Role deleted.\"}")); + MessageResponse deleted = client.team().deleteRole("role 1"); + assertTeamRequest("DELETE", "/v1/team/roles/role%201"); + assertNull(server.last().rawQuery()); + assertEquals("", server.last().body()); + assertEquals("Role deleted.", deleted.message()); + + server.enqueue(FakeServer.Response.json(200, "{\"message\":\"Role deleted.\"}")); + client.team().deleteRole("role1", DeleteTeamRoleQuery.builder().build()); + assertNull(server.last().rawQuery()); + + server.enqueue(FakeServer.Response.json(200, "{\"message\":\"Role deleted.\"}")); + client.team().deleteRole("role1", new DeleteTeamRoleQuery("role2"), RequestOptions.timeout(Duration.ofSeconds(5))); + assertTeamRequest("DELETE", "/v1/team/roles/role1"); + assertEquals("replacement_role_id=role2", server.last().rawQuery()); + + server.enqueue(FakeServer.Response.json(201, ROLE)); + TeamRoleData copy = client.team().duplicateRole("role 1", DuplicateTeamRoleData.builder().name("Support 2").build()); + assertTeamRequest("POST", "/v1/team/roles/role%201/duplicate"); + assertEquals("{\"name\":\"Support 2\"}", server.last().body()); + assertEquals("Answers tickets", copy.description()); + + server.enqueue(FakeServer.Response.json(201, ROLE)); + client.team().duplicateRole("role1", DuplicateTeamRoleData.builder().name("Support 2").description("Copy").build()); + assertEquals("{\"name\":\"Support 2\",\"description\":\"Copy\"}", server.last().body()); + } + + @Test + void teamRolesValidateIdsAndNeedTheTeamToken() { + UpdateTeamRoleData body = UpdateTeamRoleData.builder().name("Support").permissions(List.of()).build(); + assertThrows(LettermintConfigException.class, () -> client.team().retrieveRole("")); + assertThrows(LettermintConfigException.class, () -> client.team().updateRole("..", body)); + assertThrows(LettermintConfigException.class, () -> client.team().deleteRole(null)); + assertThrows(LettermintConfigException.class, () -> client.team().duplicateRole("", DuplicateTeamRoleData.builder().name("Copy").build())); + Lettermint sending = Lettermint.builder().sendingToken(ClientTest.SENDING).baseUrl(server.baseUrl()).build(); + assertThrows(LettermintConfigException.class, () -> sending.team().retrieveRole("role1")); + assertTrue(server.requests().isEmpty()); + } + + @Test + void teamRoleErrorsMapToApiExceptions() { + server.enqueue(FakeServer.Response.json(409, "{\"error\":{\"code\":\"built_in_role_immutable\",\"message\":\"Built-in roles cannot be changed.\"}}")); + ConflictException conflict = assertThrows(ConflictException.class, + () -> client.team().updateRole("admin", UpdateTeamRoleData.builder().name("Admin").permissions(List.of()).build())); + assertEquals(409, conflict.getStatus()); + assertEquals("built_in_role_immutable", conflict.getCode()); + assertEquals("Built-in roles cannot be changed.", conflict.getMessage()); + + server.enqueue(FakeServer.Response.json(422, "{\"message\":\"Choose a replacement role.\",\"errors\":{\"replacement_role_id\":[\"Choose a replacement role.\"]}}")); + ValidationException invalid = assertThrows(ValidationException.class, () -> client.team().deleteRole("role1")); + assertEquals(List.of("Choose a replacement role."), invalid.getErrors().get("replacement_role_id")); + } + @Test void updatesSendExplicitNulls() { client.routes().update("r1", UpdateRouteData.builder().inboundDomain(null).name("Main").build());