diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/OnCallRolesRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/OnCallRolesRestService.java new file mode 100644 index 000000000000..b05bd16069e6 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/OnCallRolesRestService.java @@ -0,0 +1,614 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.core.SecurityContext; + +import org.opennms.core.utils.OwnedInterval; +import org.opennms.core.utils.OwnedIntervalSequence; +import org.opennms.core.utils.Owner; +import org.opennms.netmgt.config.GroupManager; +import org.opennms.netmgt.config.UserManager; +import org.opennms.netmgt.config.groups.Group; +import org.opennms.netmgt.config.groups.Role; +import org.opennms.netmgt.config.groups.Schedule; +import org.opennms.netmgt.config.groups.Time; +import org.opennms.web.api.Authentication; +import org.opennms.web.rest.v2.api.OnCallRolesRestApi; +import org.opennms.web.rest.v2.model.OnCallCalendarDto; +import org.opennms.web.rest.v2.model.OnCallCalendarDto.CalendarDayDto; +import org.opennms.web.rest.v2.model.OnCallCalendarDto.CalendarEntryDto; +import org.opennms.web.rest.v2.model.OnCallRoleDto; +import org.opennms.web.rest.v2.model.OnCallRoleDto.OnCallScheduleDto; +import org.opennms.web.rest.v2.model.OnCallRoleDto.OnCallTimeDto; +import org.opennms.web.rest.v2.model.OnCallRoleRenameRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Versioned on-call role management on top of {@link GroupManager}: the roles + * section of groups.xml remains the system of record and hand-editing keeps + * working. Schedule entries already stored on a role round-trip untouched; + * entries new to a request are validated against the schema types and the + * legacy editor's rules (member of the membership group, start before end). + * The calendar endpoint reuses the exact interval resolution notifd uses, so + * what the page shows is what notifd will do. + */ +@Component("onCallRolesRestServiceV2") +public class OnCallRolesRestService implements OnCallRolesRestApi { + + private static final Logger LOG = LoggerFactory.getLogger(OnCallRolesRestService.class); + + // Check-then-act sequences synchronize on GroupFactory.class: roles live + // in groups.xml, and user/group mutations cascade through GroupManager, + // so all v2 services touching it share one monitor. + + /** Markup per the legacy servlets, plus URL-path-segment safety. */ + private static final Pattern INVALID_NAME = Pattern.compile("[&<>\"`':/\\\\%?#\\s]"); + + private static final Set SCHEDULE_TYPES = Set.of("specific", "daily", "weekly", "monthly"); + + private static final Set WEEKDAYS = Set.of("sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"); + + /** groups.xsd time formats; Locale.ROOT matches the legacy writers. */ + private static final String DATE_TIME_FORMAT = "dd-MMM-yyyy HH:mm:ss"; + private static final String TIME_FORMAT = "HH:mm:ss"; + + @Autowired + private GroupManager m_groupManager; + + @Autowired + private UserManager m_userManager; + + @Override + public Response listRoles(final SecurityContext securityContext) { + assertAdmin(securityContext); + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + // getRole()/getRoles() serve the in-memory cache without checking file + // freshness; update() reloads groups.xml if it was edited by hand. + m_groupManager.update(); + final List roles = new ArrayList<>(); + for (final Role role : m_groupManager.getRoles()) { + roles.add(toDto(role, false)); + } + roles.sort(Comparator.comparing(OnCallRoleDto::getName, String.CASE_INSENSITIVE_ORDER)); + return Response.ok(roles).build(); + } + } catch (final Exception e) { + return serverError("Can't read on-call roles: %s", e); + } + } + + @Override + public Response getRole(final SecurityContext securityContext, final String name) { + assertAdmin(securityContext); + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + m_groupManager.update(); + final Role role = m_groupManager.getRole(name); + if (role == null) { + return Response.status(Status.NOT_FOUND).entity("On-call role " + name + " was not found.").build(); + } + return Response.ok(toDto(role, true)).build(); + } + } catch (final Exception e) { + return serverError("Can't read on-call role: %s", e); + } + } + + @Override + public Response getCalendar(final SecurityContext securityContext, final String name, final Integer year, final Integer month) { + assertAdmin(securityContext); + if (year == null || month == null || month < 1 || month > 12 || year < 2000 || year > 2100) { + return Response.status(Status.BAD_REQUEST).entity("year (2000-2100) and month (1-12) are required.").build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + m_groupManager.update(); + if (m_groupManager.getRole(name) == null) { + return Response.status(Status.NOT_FOUND).entity("On-call role " + name + " was not found.").build(); + } + final OnCallCalendarDto calendar = new OnCallCalendarDto(); + calendar.setRole(name); + calendar.setYear(year); + calendar.setMonth(month); + final YearMonth yearMonth = YearMonth.of(year, month); + final ZoneId zone = ZoneId.systemDefault(); + calendar.setTimeZone(zone.getId()); + for (int dayOfMonth = 1; dayOfMonth <= yearMonth.lengthOfMonth(); dayOfMonth++) { + final LocalDate date = yearMonth.atDay(dayOfMonth); + final Date dayStart = Date.from(date.atStartOfDay(zone).toInstant()); + final Date dayEnd = Date.from(date.plusDays(1).atStartOfDay(zone).toInstant()); + final CalendarDayDto dayDto = new CalendarDayDto(); + dayDto.setDate(date.toString()); + final OwnedIntervalSequence intervals = m_groupManager.getRoleScheduleEntries(name, dayStart, dayEnd); + for (final java.util.Iterator it = intervals.iterator(); it.hasNext();) { + final OwnedInterval interval = it.next(); + final CalendarEntryDto entry = new CalendarEntryDto(); + entry.setStart(interval.getStart().getTime()); + entry.setEnd(interval.getEnd().getTime()); + boolean supervisor = false; + final Set users = new LinkedHashSet<>(); + for (final Owner owner : interval.getOwners()) { + users.add(owner.getUser()); + supervisor |= owner.isSupervisor(); + } + entry.setUsers(new ArrayList<>(users)); + entry.setSupervisor(supervisor); + dayDto.getEntries().add(entry); + } + calendar.getDays().add(dayDto); + } + return Response.ok(calendar).build(); + } + } catch (final Exception e) { + return serverError("Can't compute the on-call calendar: %s", e); + } + } + + @Override + public Response createRole(final SecurityContext securityContext, final OnCallRoleDto dto) { + assertAdmin(securityContext); + if (dto == null || isBlank(dto.getName())) { + return Response.status(Status.BAD_REQUEST).entity("A role name is required.").build(); + } + final String name = dto.getName().trim(); + final String nameProblem = validateName(name); + if (nameProblem != null) { + return Response.status(Status.BAD_REQUEST).entity(nameProblem).build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + m_groupManager.update(); + if (m_groupManager.getRole(name) != null) { + return Response.status(Status.BAD_REQUEST).entity("On-call role " + name + " already exists.").build(); + } + validateDtoFields(dto, null); + final Role role = new Role(); + role.setName(name); + applyDto(role, dto); + m_groupManager.saveRole(role); + } + LOG.info("On-call role {} created by {}", name, principal(securityContext)); + return Response.status(Status.CREATED).build(); + } catch (final Exception e) { + return serverError("Can't create on-call role: %s", e); + } + } + + @Override + public Response updateRole(final SecurityContext securityContext, final String name, final OnCallRoleDto dto) { + assertAdmin(securityContext); + if (dto == null) { + return Response.status(Status.BAD_REQUEST).entity("A role body is required.").build(); + } + if (dto.getName() != null && !name.equals(dto.getName())) { + return Response.status(Status.BAD_REQUEST) + .entity("The name in the body does not match the request path; use the rename endpoint to change names.").build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + m_groupManager.update(); + final Role existing = m_groupManager.getRole(name); + if (existing == null) { + return Response.status(Status.NOT_FOUND).entity("On-call role " + name + " was not found.").build(); + } + validateDtoFields(dto, existing); + final Role updated = copyOf(existing); + applyDto(updated, dto); + m_groupManager.saveRole(updated); + } + return Response.noContent().build(); + } catch (final Exception e) { + return serverError("Can't update on-call role: %s", e); + } + } + + @Override + public Response renameRole(final SecurityContext securityContext, final String name, final OnCallRoleRenameRequest request) { + assertAdmin(securityContext); + if (request == null || isBlank(request.getNewName())) { + return Response.status(Status.BAD_REQUEST).entity("A new-name is required.").build(); + } + final String newName = request.getNewName().trim(); + final String nameProblem = validateName(newName); + if (nameProblem != null) { + return Response.status(Status.BAD_REQUEST).entity(nameProblem).build(); + } + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + m_groupManager.update(); + final Role existing = m_groupManager.getRole(name); + if (existing == null) { + return Response.status(Status.NOT_FOUND).entity("On-call role " + name + " was not found.").build(); + } + if (m_groupManager.getRole(newName) != null) { + return Response.status(Status.BAD_REQUEST).entity("On-call role " + newName + " already exists.").build(); + } + // save the copy under the new name FIRST so a failure between + // the two writes leaves both roles present, never neither + final Role renamed = copyOf(existing); + renamed.setName(newName); + m_groupManager.saveRole(renamed); + m_groupManager.deleteRole(name); + } + LOG.info("On-call role {} renamed to {} by {}", name, newName, principal(securityContext)); + return Response.noContent().build(); + } catch (final Exception e) { + return serverError("Can't rename on-call role: %s", e); + } + } + + @Override + public Response deleteRole(final SecurityContext securityContext, final String name) { + assertAdmin(securityContext); + try { + synchronized (org.opennms.netmgt.config.GroupFactory.class) { + m_groupManager.update(); + if (m_groupManager.getRole(name) == null) { + return Response.status(Status.NOT_FOUND).entity("On-call role " + name + " was not found.").build(); + } + m_groupManager.deleteRole(name); + } + LOG.info("On-call role {} deleted by {}", name, principal(securityContext)); + return Response.noContent().build(); + } catch (final Exception e) { + return serverError("Can't delete on-call role: %s", e); + } + } + + private OnCallRoleDto toDto(final Role role, final boolean includeSchedules) throws Exception { + final OnCallRoleDto dto = new OnCallRoleDto(); + dto.setName(role.getName()); + dto.setMembershipGroup(role.getMembershipGroup()); + dto.setSupervisor(role.getSupervisor()); + dto.setDescription(role.getDescription().orElse(null)); + // hand-edited day values the runtime can't resolve (e.g. day="mon") + // make schedule evaluation throw; one bad role must not 500 the list + List onCallUsers = List.of(); + try { + final String[] onCall = m_userManager.getUsersScheduledForRole(role.getName(), new Date()); + onCallUsers = onCall == null ? List.of() : List.of(onCall); + } catch (final Exception e) { + LOG.warn("Can't evaluate the schedule of on-call role {}: {}", role.getName(), e.toString()); + // an unevaluable schedule must be distinguishable from an idle + // one: notifd cannot resolve this rota either + dto.setScheduleError("The schedule cannot be evaluated; check this role's entries in groups.xml."); + } + dto.setCurrentlyOnCall(onCallUsers); + if (includeSchedules) { + final List schedules = new ArrayList<>(); + for (final Schedule schedule : role.getSchedules()) { + schedules.add(toScheduleDto(schedule)); + } + dto.setSchedules(schedules); + } + return dto; + } + + private static OnCallScheduleDto toScheduleDto(final Schedule schedule) { + final OnCallScheduleDto dto = new OnCallScheduleDto(); + dto.setUser(schedule.getName()); + dto.setType(schedule.getType()); + final List times = new ArrayList<>(); + for (final Time time : schedule.getTimes()) { + final OnCallTimeDto timeDto = new OnCallTimeDto(); + timeDto.setId(time.getId().orElse(null)); + timeDto.setDay(time.getDay().orElse(null)); + timeDto.setBegins(time.getBegins()); + timeDto.setEnds(time.getEnds()); + times.add(timeDto); + } + dto.setTimes(times); + return dto; + } + + /** Detached deep copy so mutations never touch the manager's live object. */ + private static Role copyOf(final Role role) { + final Role copy = new Role(); + copy.setName(role.getName()); + copy.setMembershipGroup(role.getMembershipGroup()); + copy.setSupervisor(role.getSupervisor()); + role.getDescription().ifPresent(copy::setDescription); + for (final Schedule schedule : role.getSchedules()) { + copy.getSchedules().add(copyOf(schedule)); + } + return copy; + } + + private static Schedule copyOf(final Schedule schedule) { + final Schedule copy = new Schedule(); + copy.setName(schedule.getName()); + copy.setType(schedule.getType()); + for (final Time time : schedule.getTimes()) { + final Time timeCopy = new Time(); + time.getId().ifPresent(timeCopy::setId); + time.getDay().ifPresent(timeCopy::setDay); + timeCopy.setBegins(time.getBegins()); + timeCopy.setEnds(time.getEnds()); + copy.getTimes().add(timeCopy); + } + return copy; + } + + /** + * Validates everything BEFORE anything is applied. Schedules already + * stored on the role (matched by canonical form) round-trip untouched so + * hand-edited files never make a role uneditable; schedules new to the + * request must pass the schema and legacy-editor rules. On create, and + * whenever supervisor/membership-group are being set, those must exist. + */ + private void validateDtoFields(final OnCallRoleDto dto, final Role existing) throws Exception { + // on create both identity fields are required; on update each is + // validated independently so one can be changed without the other + final String supervisor = trimToNull(dto.getSupervisor()); + if (existing == null || dto.getSupervisor() != null) { + if (supervisor == null || !m_userManager.hasUser(supervisor)) { + throw new IllegalArgumentException("A supervisor is required and must be an existing user."); + } + } + final String membershipGroup = trimToNull(dto.getMembershipGroup()); + if (existing == null || dto.getMembershipGroup() != null) { + if (membershipGroup == null || !m_groupManager.hasGroup(membershipGroup)) { + throw new IllegalArgumentException("A membership-group is required and must be an existing group."); + } + } + if (dto.getSchedules() != null) { + final String scheduleGroup = membershipGroup != null + ? membershipGroup + : existing == null ? null : existing.getMembershipGroup(); + final Group group = scheduleGroup == null ? null : m_groupManager.getGroup(scheduleGroup); + final Set members = group == null ? Set.of() : new LinkedHashSet<>(group.getUsers()); + final Set preExisting = existing == null ? Set.of() + : existing.getSchedules().stream().map(OnCallRolesRestService::canonical).collect(Collectors.toSet()); + for (final OnCallScheduleDto schedule : dto.getSchedules()) { + if (!preExisting.contains(canonical(schedule))) { + validateSchedule(schedule, members); + } + } + } + } + + private void validateSchedule(final OnCallScheduleDto schedule, final Set members) { + final String user = trimToNull(schedule.getUser()); + if (user == null) { + throw new IllegalArgumentException("Every schedule requires a user."); + } + if (!members.contains(user)) { + throw new IllegalArgumentException("Schedule user " + user + " is not a member of the role's membership group."); + } + final String type = trimToNull(schedule.getType()); + if (type == null || !SCHEDULE_TYPES.contains(type)) { + throw new IllegalArgumentException("Schedule type must be one of " + SCHEDULE_TYPES + "."); + } + if (schedule.getTimes() == null || schedule.getTimes().isEmpty()) { + throw new IllegalArgumentException("Every schedule requires at least one time entry."); + } + for (final OnCallTimeDto time : schedule.getTimes()) { + validateTime(type, time); + } + } + + /** + * Validates AND canonicalizes: begins/ends are rewritten into the exact + * zero-padded form the runtime dispatches on (setOutCalTime switches on + * string length 20/8 and parses with the JVM default locale), and weekly + * days are lowercased. Accepting anything looser would store entries + * notifd silently ignores. + */ + private void validateTime(final String type, final OnCallTimeDto time) { + final String day = trimToNull(time.getDay()); + switch (type) { + case "specific": + if (day != null) { + throw new IllegalArgumentException("A specific schedule must not carry a day."); + } + final Date begins = parseDate(DATE_TIME_FORMAT, time.getBegins()); + final Date ends = parseDate(DATE_TIME_FORMAT, time.getEnds()); + if (!begins.before(ends)) { + throw new IllegalArgumentException("The start time must be before the end time."); + } + time.setBegins(runtimeDateTimeString(begins)); + time.setEnds(runtimeDateTimeString(ends)); + return; + case "daily": + if (day != null) { + throw new IllegalArgumentException("A daily schedule must not carry a day."); + } + break; + case "weekly": + if (day == null || !WEEKDAYS.contains(day.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("A weekly schedule requires a weekday name as its day."); + } + time.setDay(day.toLowerCase(Locale.ROOT)); + break; + case "monthly": + if (day == null || !day.matches("0?[1-9]|[1-2][0-9]|3[0-1]")) { + throw new IllegalArgumentException("A monthly schedule requires a day of month (1-31)."); + } + // the groups.xsd day pattern forbids leading zeros + time.setDay(String.valueOf(Integer.parseInt(day))); + break; + default: + throw new IllegalArgumentException("Unsupported schedule type: " + type); + } + final Date start = parseDate(TIME_FORMAT, time.getBegins()); + final Date end = parseDate(TIME_FORMAT, time.getEnds()); + if (!start.before(end)) { + throw new IllegalArgumentException("The start time must be before the end time."); + } + time.setBegins(strictFormat(TIME_FORMAT, Locale.ROOT).format(start)); + time.setEnds(strictFormat(TIME_FORMAT, Locale.ROOT).format(end)); + } + + private static Date parseDate(final String format, final String value) { + if (value == null) { + throw new IllegalArgumentException("Schedule times require begins and ends values."); + } + try { + return strictFormat(format, Locale.ROOT).parse(value.trim()); + } catch (final ParseException e) { + throw new IllegalArgumentException("Invalid schedule time '" + value + "': expected the format " + format); + } + } + + /** + * The runtime (BasicScheduleUtils.setOutCalTime) parses stored dd-MMM-yyyy + * strings with the JVM default locale. Always store the canonical English + * form (fixed width, matches what the API accepts and returns) and reject + * when the default locale cannot parse it back — otherwise the entry would + * be stored but silently ignored by notifd. The check is locale-level, not + * month-level, so the same request never flips between accept and reject. + */ + private static String runtimeDateTimeString(final Date date) { + final String stored = strictFormat(DATE_TIME_FORMAT, Locale.ROOT).format(date); + try { + if (!date.equals(strictFormat(DATE_TIME_FORMAT, Locale.getDefault()).parse(stored))) { + throw new ParseException(stored, 0); + } + } catch (final ParseException e) { + throw new IllegalArgumentException("The server locale " + Locale.getDefault() + + " cannot parse the dd-MMM-yyyy schedule format the scheduler requires, so specific schedules would be ignored at runtime."); + } + return stored; + } + + private static SimpleDateFormat strictFormat(final String format, final Locale locale) { + final SimpleDateFormat dateFormat = new SimpleDateFormat(format, locale); + dateFormat.setLenient(false); + return dateFormat; + } + + /** Canonical form used to recognize schedules that were already stored. */ + private static String canonical(final Schedule schedule) { + return canonical(schedule.getName(), schedule.getType(), + schedule.getTimes().stream() + .map(t -> (t.getDay().orElse("") + "|" + t.getBegins() + "|" + t.getEnds())) + .collect(Collectors.toList())); + } + + private static String canonical(final OnCallScheduleDto schedule) { + return canonical(schedule.getUser(), schedule.getType(), + schedule.getTimes() == null ? List.of() + : schedule.getTimes().stream() + .map(t -> ((t.getDay() == null ? "" : t.getDay()) + "|" + t.getBegins() + "|" + t.getEnds())) + .collect(Collectors.toList())); + } + + private static String canonical(final String user, final String type, final List times) { + return user + "//" + type + "//" + String.join(";;", times); + } + + private void applyDto(final Role role, final OnCallRoleDto dto) { + if (dto.getSupervisor() != null) { + role.setSupervisor(dto.getSupervisor().trim()); + } + if (dto.getMembershipGroup() != null) { + role.setMembershipGroup(dto.getMembershipGroup().trim()); + } + if (dto.getDescription() != null) { + role.setDescription(trimToNull(dto.getDescription())); + } + if (dto.getSchedules() != null) { + role.getSchedules().clear(); + for (final OnCallScheduleDto scheduleDto : dto.getSchedules()) { + final Schedule schedule = new Schedule(); + schedule.setName(scheduleDto.getUser().trim()); + schedule.setType(scheduleDto.getType().trim()); + for (final OnCallTimeDto timeDto : scheduleDto.getTimes()) { + final Time time = new Time(); + if (trimToNull(timeDto.getId()) != null) { + time.setId(timeDto.getId().trim()); + } + if (trimToNull(timeDto.getDay()) != null) { + time.setDay(timeDto.getDay().trim()); + } + time.setBegins(timeDto.getBegins().trim()); + time.setEnds(timeDto.getEnds().trim()); + schedule.getTimes().add(time); + } + role.getSchedules().add(schedule); + } + } + } + + private static String validateName(final String name) { + if (INVALID_NAME.matcher(name).find()) { + return "The role name must not contain markup, whitespace, or the characters : / \\ % ? #"; + } + if (".".equals(name) || "..".equals(name)) { + return "The role name must not be a dot segment."; + } + return null; + } + + private static void assertAdmin(final SecurityContext securityContext) { + if (securityContext == null || !securityContext.isUserInRole(Authentication.ROLE_ADMIN)) { + throw new javax.ws.rs.WebApplicationException( + Response.status(Status.FORBIDDEN).entity("On-call role management requires the admin role.").build()); + } + } + + private static String principal(final SecurityContext securityContext) { + return securityContext.getUserPrincipal() == null ? "?" : securityContext.getUserPrincipal().getName(); + } + + private Response serverError(final String format, final Exception e) { + if (e instanceof IllegalArgumentException) { + return Response.status(Status.BAD_REQUEST).entity(e.getMessage()).build(); + } + LOG.error(String.format(format, e.getMessage()), e); + return Response.status(Status.INTERNAL_SERVER_ERROR).entity(String.format(format, e.getMessage())).build(); + } + + private static boolean isBlank(final String value) { + return value == null || value.isBlank(); + } + + private static String trimToNull(final String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/OnCallRolesRestApi.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/OnCallRolesRestApi.java new file mode 100644 index 000000000000..489398bade96 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/OnCallRolesRestApi.java @@ -0,0 +1,91 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2.api; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; + +import org.opennms.web.rest.v2.model.OnCallRoleDto; +import org.opennms.web.rest.v2.model.OnCallRoleRenameRequest; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Versioned on-call role management API backed by the roles section of + * groups.xml. Admin-only (enforced by Spring Security and in-code). + */ +@Path("on-call-roles") +@Tag(name = "On-Call-Roles", description = "On-Call Role Management API") +public interface OnCallRolesRestApi { + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "List all on-call roles", operationId = "listOnCallRoles") + Response listRoles(@Context SecurityContext securityContext); + + @GET + @Path("{name}") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get one on-call role including its schedules", operationId = "getOnCallRole") + Response getRole(@Context SecurityContext securityContext, @PathParam("name") String name); + + @GET + @Path("{name}/calendar") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Month view of who is on call, computed with the same resolution notifd uses (supervisor fills uncovered intervals)", operationId = "getOnCallCalendar") + Response getCalendar(@Context SecurityContext securityContext, @PathParam("name") String name, + @QueryParam("year") Integer year, @QueryParam("month") Integer month); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Create an on-call role", operationId = "createOnCallRole") + Response createRole(@Context SecurityContext securityContext, OnCallRoleDto role); + + @PUT + @Path("{name}") + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Update an on-call role (an omitted schedule list is preserved; a provided one replaces the schedules)", operationId = "updateOnCallRole") + Response updateRole(@Context SecurityContext securityContext, @PathParam("name") String name, OnCallRoleDto role); + + @POST + @Path("{name}/rename") + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Rename an on-call role", operationId = "renameOnCallRole") + Response renameRole(@Context SecurityContext securityContext, @PathParam("name") String name, OnCallRoleRenameRequest request); + + @DELETE + @Path("{name}") + @Operation(summary = "Delete an on-call role", operationId = "deleteOnCallRole") + Response deleteRole(@Context SecurityContext securityContext, @PathParam("name") String name); +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/OnCallCalendarDto.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/OnCallCalendarDto.java new file mode 100644 index 000000000000..22332fc2cd6c --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/OnCallCalendarDto.java @@ -0,0 +1,172 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2.model; + +import java.util.ArrayList; +import java.util.List; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +/** + * One month of on-call coverage for a role, computed server-side from the + * same schedule resolution notifd uses. Intervals where nobody is scheduled + * fall to the supervisor and carry the supervisor flag. + */ +@XmlRootElement(name = "on-call-calendar") +@XmlAccessorType(XmlAccessType.FIELD) +public class OnCallCalendarDto { + + @XmlElement(name = "role") + private String role; + + @XmlElement(name = "year") + private int year; + + @XmlElement(name = "month") + private int month; + + /** IANA id of the zone the server evaluates schedules in; clients should render and enter times in it. */ + @XmlElement(name = "time-zone") + private String timeZone; + + @XmlElement(name = "day") + private List days = new ArrayList<>(); + + public String getRole() { + return role; + } + + public void setRole(final String role) { + this.role = role; + } + + public int getYear() { + return year; + } + + public void setYear(final int year) { + this.year = year; + } + + public int getMonth() { + return month; + } + + public void setMonth(final int month) { + this.month = month; + } + + public String getTimeZone() { + return timeZone; + } + + public void setTimeZone(final String timeZone) { + this.timeZone = timeZone; + } + + public List getDays() { + return days; + } + + public void setDays(final List days) { + this.days = days; + } + + @XmlRootElement(name = "on-call-calendar-day") + @XmlAccessorType(XmlAccessType.FIELD) + public static class CalendarDayDto { + + @XmlElement(name = "date") + private String date; + + @XmlElement(name = "entry") + private List entries = new ArrayList<>(); + + public String getDate() { + return date; + } + + public void setDate(final String date) { + this.date = date; + } + + public List getEntries() { + return entries; + } + + public void setEntries(final List entries) { + this.entries = entries; + } + } + + @XmlRootElement(name = "on-call-calendar-entry") + @XmlAccessorType(XmlAccessType.FIELD) + public static class CalendarEntryDto { + + @XmlElement(name = "start") + private long start; + + @XmlElement(name = "end") + private long end; + + @XmlElement(name = "user") + private List users = new ArrayList<>(); + + @XmlElement(name = "supervisor") + private boolean supervisor; + + public long getStart() { + return start; + } + + public void setStart(final long start) { + this.start = start; + } + + public long getEnd() { + return end; + } + + public void setEnd(final long end) { + this.end = end; + } + + public List getUsers() { + return users; + } + + public void setUsers(final List users) { + this.users = users; + } + + public boolean isSupervisor() { + return supervisor; + } + + public void setSupervisor(final boolean supervisor) { + this.supervisor = supervisor; + } + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/OnCallRoleDto.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/OnCallRoleDto.java new file mode 100644 index 000000000000..08290ef8e07c --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/OnCallRoleDto.java @@ -0,0 +1,207 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2.model; + +import java.util.List; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +/** + * An on-call role as exposed by the v2 API. Field names mirror the role + * element in groups.xml. The schedule list is the raw file representation + * (schedule name = the user on call; begins/ends in the file's own formats); + * a schedule list omitted from a request body means "preserve". + * currently-on-call is computed and ignored on writes. + */ +@XmlRootElement(name = "on-call-role") +@XmlAccessorType(XmlAccessType.FIELD) +public class OnCallRoleDto { + + @XmlElement(name = "name") + private String name; + + @XmlElement(name = "membership-group") + private String membershipGroup; + + @XmlElement(name = "supervisor") + private String supervisor; + + @XmlElement(name = "description") + private String description; + + @XmlElement(name = "schedule") + private List schedules; + + @XmlElement(name = "currently-on-call") + private List currentlyOnCall; + + /** Set when the runtime cannot evaluate the stored schedule; read-only. */ + @XmlElement(name = "schedule-error") + private String scheduleError; + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public String getMembershipGroup() { + return membershipGroup; + } + + public void setMembershipGroup(final String membershipGroup) { + this.membershipGroup = membershipGroup; + } + + public String getSupervisor() { + return supervisor; + } + + public void setSupervisor(final String supervisor) { + this.supervisor = supervisor; + } + + public String getDescription() { + return description; + } + + public void setDescription(final String description) { + this.description = description; + } + + public List getSchedules() { + return schedules; + } + + public void setSchedules(final List schedules) { + this.schedules = schedules; + } + + public List getCurrentlyOnCall() { + return currentlyOnCall; + } + + public void setCurrentlyOnCall(final List currentlyOnCall) { + this.currentlyOnCall = currentlyOnCall; + } + + public String getScheduleError() { + return scheduleError; + } + + public void setScheduleError(final String scheduleError) { + this.scheduleError = scheduleError; + } + + @XmlRootElement(name = "on-call-schedule") + @XmlAccessorType(XmlAccessType.FIELD) + public static class OnCallScheduleDto { + + @XmlElement(name = "user") + private String user; + + @XmlElement(name = "type") + private String type; + + @XmlElement(name = "time") + private List times; + + public String getUser() { + return user; + } + + public void setUser(final String user) { + this.user = user; + } + + public String getType() { + return type; + } + + public void setType(final String type) { + this.type = type; + } + + public List getTimes() { + return times; + } + + public void setTimes(final List times) { + this.times = times; + } + } + + @XmlRootElement(name = "on-call-time") + @XmlAccessorType(XmlAccessType.FIELD) + public static class OnCallTimeDto { + + /** Optional id attribute some hand-edited files carry; round-trips untouched. */ + @XmlElement(name = "id") + private String id; + + @XmlElement(name = "day") + private String day; + + @XmlElement(name = "begins") + private String begins; + + @XmlElement(name = "ends") + private String ends; + + public String getId() { + return id; + } + + public void setId(final String id) { + this.id = id; + } + + public String getDay() { + return day; + } + + public void setDay(final String day) { + this.day = day; + } + + public String getBegins() { + return begins; + } + + public void setBegins(final String begins) { + this.begins = begins; + } + + public String getEnds() { + return ends; + } + + public void setEnds(final String ends) { + this.ends = ends; + } + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/OnCallRoleRenameRequest.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/OnCallRoleRenameRequest.java new file mode 100644 index 000000000000..f6fcaf430e13 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/model/OnCallRoleRenameRequest.java @@ -0,0 +1,43 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +@XmlRootElement(name = "on-call-role-rename-request") +@XmlAccessorType(XmlAccessType.FIELD) +public class OnCallRoleRenameRequest { + + @XmlElement(name = "new-name") + private String newName; + + public String getNewName() { + return newName; + } + + public void setNewName(final String newName) { + this.newName = newName; + } +} diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json index d5a81390467e..230c15de2337 100644 --- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json +++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json @@ -418,7 +418,7 @@ { "id": "manageOnCall", "name": "Manage On-call Roles", - "url": "admin/userGroupView/roles", + "url": "ui/index.html#/admin/oncall-roles", "locationMatch": "", "roles": null } diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/OnCallRolesRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/OnCallRolesRestServiceIT.java new file mode 100644 index 000000000000..1f2a3344fc2d --- /dev/null +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/OnCallRolesRestServiceIT.java @@ -0,0 +1,477 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import javax.ws.rs.core.MediaType; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.test.MockLogAppender; +import org.opennms.core.test.OpenNMSJUnit4ClassRunner; +import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; +import org.opennms.core.test.rest.AbstractSpringJerseyRestTestCase; +import org.opennms.netmgt.config.GroupManager; +import org.opennms.netmgt.config.UserManager; +import org.opennms.netmgt.config.groups.Group; +import org.opennms.netmgt.config.groups.Role; +import org.opennms.netmgt.config.groups.Schedule; +import org.opennms.netmgt.config.groups.Time; +import org.opennms.netmgt.config.users.User; +import org.opennms.test.JUnitConfigurationEnvironment; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.web.WebAppConfiguration; + +@RunWith(OpenNMSJUnit4ClassRunner.class) +@WebAppConfiguration +@ContextConfiguration(locations={ + "classpath:/META-INF/opennms/applicationContext-soa.xml", + "classpath:/META-INF/opennms/applicationContext-commonConfigs.xml", + "classpath:/META-INF/opennms/applicationContext-minimal-conf.xml", + "classpath:/META-INF/opennms/applicationContext-dao.xml", + "classpath:/META-INF/opennms/applicationContext-mockConfigManager.xml", + "classpath*:/META-INF/opennms/component-service.xml", + "classpath*:/META-INF/opennms/component-dao.xml", + "classpath:/META-INF/opennms/applicationContext-databasePopulator.xml", + "classpath:/META-INF/opennms/mockEventIpcManager.xml", + "file:src/main/webapp/WEB-INF/applicationContext-svclayer.xml", + "file:src/main/webapp/WEB-INF/applicationContext-cxf-common.xml", + // in-memory user/group managers so groups.xml is never touched + "classpath:/META-INF/opennms/applicationContext-mock-usergroup.xml", + "classpath:/applicationContext-rest-test.xml" +}) +@JUnitConfigurationEnvironment(systemProperties = "org.opennms.timeseries.strategy=integration") +@JUnitTemporaryDatabase +public class OnCallRolesRestServiceIT extends AbstractSpringJerseyRestTestCase { + + @Autowired + private GroupManager m_groupManager; + + @Autowired + private UserManager m_userManager; + + public OnCallRolesRestServiceIT() { + super(CXF_REST_V2_CONTEXT_PATH); + } + + @Override + protected void beforeServletStart() { + MockLogAppender.setupLogging(); + } + + private void ensureUser(final String userId) throws Exception { + if (!m_userManager.hasUser(userId)) { + final User user = new User(); + user.setUserId(userId); + user.setPassword(m_userManager.encryptedPassword("pw", true), Boolean.TRUE); + m_userManager.saveUser(userId, user); + } + } + + private void ensureGroup(final String name, final String... members) throws Exception { + final Group group = new Group(); + group.setName(name); + for (final String member : members) { + ensureUser(member); + group.addUser(member); + } + m_groupManager.saveGroup(name, group); + } + + @Test + public void testCreateLifecycle() throws Exception { + ensureGroup("noc-team", "oncall1", "oncall2"); + final String body = "{\"name\":\"junit-role\",\"membership-group\":\"noc-team\",\"supervisor\":\"admin\"," + + "\"description\":\"junit on-call\"," + + "\"schedule\":[{\"user\":\"oncall1\",\"type\":\"specific\"," + + "\"time\":[{\"begins\":\"15-Jun-2093 08:00:00\",\"ends\":\"15-Jun-2093 17:00:00\"}]}]}"; + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", body, 201); + + final JSONObject created = new JSONObject(getJson("/on-call-roles/junit-role", 200)); + assertEquals("noc-team", created.getString("membership-group")); + assertEquals("admin", created.getString("supervisor")); + assertEquals("junit on-call", created.getString("description")); + final JSONObject schedule = created.getJSONArray("schedule").getJSONObject(0); + assertEquals("oncall1", schedule.getString("user")); + assertEquals("specific", schedule.getString("type")); + assertEquals("15-Jun-2093 08:00:00", schedule.getJSONArray("time").getJSONObject(0).getString("begins")); + + // creating the same role again must be rejected + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", body, 400); + + sendRequest(DELETE, "/on-call-roles/junit-role", 204); + sendRequest(GET, "/on-call-roles/junit-role", 404); + } + + @Test + public void testListIncludesCurrentlyOnCall() throws Exception { + ensureGroup("list-team", "listuser"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"list-role\",\"membership-group\":\"list-team\",\"supervisor\":\"admin\"}", 201); + final JSONArray roles = new JSONArray(getJson("/on-call-roles", 200)); + boolean found = false; + for (int i = 0; i < roles.length(); i++) { + final JSONObject role = roles.getJSONObject(i); + if ("list-role".equals(role.getString("name"))) { + found = true; + // nobody scheduled -> nobody on call (supervisor is a + // notification fallback, not a scheduled user) + assertTrue(role.has("currently-on-call")); + } + } + assertTrue(found); + sendRequest(DELETE, "/on-call-roles/list-role", 204); + } + + @Test + public void testCreateValidation() throws Exception { + ensureGroup("val-team", "valuser"); + // name problems + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", "{\"name\":\"bad\",\"membership-group\":\"val-team\",\"supervisor\":\"admin\"}", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", "{\"name\":\"a/b\",\"membership-group\":\"val-team\",\"supervisor\":\"admin\"}", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", "{\"name\":\"..\",\"membership-group\":\"val-team\",\"supervisor\":\"admin\"}", 400); + // identity problems + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", "{\"name\":\"v1\",\"membership-group\":\"nosuchgroup\",\"supervisor\":\"admin\"}", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", "{\"name\":\"v2\",\"membership-group\":\"val-team\",\"supervisor\":\"nosuchuser\"}", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", "{\"name\":\"v3\",\"supervisor\":\"admin\"}", 400); + // schedule problems + final String prefix = "{\"name\":\"v4\",\"membership-group\":\"val-team\",\"supervisor\":\"admin\",\"schedule\":["; + // user not a member of the membership group + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", prefix + + "{\"user\":\"admin\",\"type\":\"specific\",\"time\":[{\"begins\":\"15-Jun-2093 08:00:00\",\"ends\":\"15-Jun-2093 17:00:00\"}]}]}", 400); + // unknown type + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", prefix + + "{\"user\":\"valuser\",\"type\":\"sometimes\",\"time\":[{\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}]}", 400); + // specific with start after end + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", prefix + + "{\"user\":\"valuser\",\"type\":\"specific\",\"time\":[{\"begins\":\"15-Jun-2093 17:00:00\",\"ends\":\"15-Jun-2093 08:00:00\"}]}]}", 400); + // specific with bad date format + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", prefix + + "{\"user\":\"valuser\",\"type\":\"specific\",\"time\":[{\"begins\":\"2093-06-15 08:00\",\"ends\":\"2093-06-15 17:00\"}]}]}", 400); + // weekly without a weekday + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", prefix + + "{\"user\":\"valuser\",\"type\":\"weekly\",\"time\":[{\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}]}", 400); + // monthly with an out-of-range day + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", prefix + + "{\"user\":\"valuser\",\"type\":\"monthly\",\"time\":[{\"day\":\"32\",\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}]}", 400); + // schedule without times + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", prefix + + "{\"user\":\"valuser\",\"type\":\"daily\",\"time\":[]}]}", 400); + // none of the rejects may have been created + for (final String name : new String[]{"v1","v2","v3","v4"}) { + sendRequest(GET, "/on-call-roles/" + name, 404); + } + } + + @Test + public void testWeeklyAndDailySchedulesAccepted() throws Exception { + ensureGroup("shift-team", "shifter"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"shift-role\",\"membership-group\":\"shift-team\",\"supervisor\":\"admin\",\"schedule\":[" + + "{\"user\":\"shifter\",\"type\":\"weekly\",\"time\":[{\"day\":\"monday\",\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}," + + "{\"user\":\"shifter\",\"type\":\"daily\",\"time\":[{\"begins\":\"18:00:00\",\"ends\":\"20:00:00\"}]}]}", 201); + final JSONObject role = new JSONObject(getJson("/on-call-roles/shift-role", 200)); + assertEquals(2, role.getJSONArray("schedule").length()); + sendRequest(DELETE, "/on-call-roles/shift-role", 204); + } + + @Test + public void testHandEditedScheduleStaysEditable() throws Exception { + // a hand-edited groups.xml may carry schedules the API's validation + // would reject (e.g. a user no longer in the membership group); the + // role must remain editable when they round-trip unchanged + ensureGroup("legacy-team", "member1"); + ensureUser("outsider"); + final Role role = new Role(); + role.setName("legacy-role"); + role.setMembershipGroup("legacy-team"); + role.setSupervisor("admin"); + final Schedule schedule = new Schedule(); + schedule.setName("outsider"); + schedule.setType("weekly"); + final Time time = new Time(); + time.setDay("friday"); + time.setBegins("08:00:00"); + time.setEnds("17:00:00"); + schedule.getTimes().add(time); + role.getSchedules().add(schedule); + m_groupManager.saveRole(role); + + // round-trip the schedule unchanged while editing the description + sendData(PUT, MediaType.APPLICATION_JSON, "/on-call-roles/legacy-role", + "{\"name\":\"legacy-role\",\"description\":\"touched\",\"schedule\":[" + + "{\"user\":\"outsider\",\"type\":\"weekly\",\"time\":[{\"day\":\"friday\",\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}]}", 204); + final JSONObject after = new JSONObject(getJson("/on-call-roles/legacy-role", 200)); + assertEquals("touched", after.getString("description")); + assertEquals("outsider", after.getJSONArray("schedule").getJSONObject(0).getString("user")); + + // but a NEW schedule for a non-member is still rejected + sendData(PUT, MediaType.APPLICATION_JSON, "/on-call-roles/legacy-role", + "{\"name\":\"legacy-role\",\"schedule\":[" + + "{\"user\":\"outsider\",\"type\":\"weekly\",\"time\":[{\"day\":\"friday\",\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}," + + "{\"user\":\"outsider\",\"type\":\"daily\",\"time\":[{\"begins\":\"01:00:00\",\"ends\":\"02:00:00\"}]}]}", 400); + + sendRequest(DELETE, "/on-call-roles/legacy-role", 204); + } + + @Test + public void testOmittedSchedulesArePreserved() throws Exception { + ensureGroup("keep-team", "keeper"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"keep-role\",\"membership-group\":\"keep-team\",\"supervisor\":\"admin\",\"schedule\":[" + + "{\"user\":\"keeper\",\"type\":\"daily\",\"time\":[{\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}]}", 201); + + // a body without the schedule key preserves the schedules + sendData(PUT, MediaType.APPLICATION_JSON, "/on-call-roles/keep-role", + "{\"name\":\"keep-role\",\"description\":\"described\"}", 204); + final JSONObject after = new JSONObject(getJson("/on-call-roles/keep-role", 200)); + assertEquals(1, after.getJSONArray("schedule").length()); + assertEquals("described", after.getString("description")); + + sendRequest(DELETE, "/on-call-roles/keep-role", 204); + } + + @Test + public void testRejectedUpdateLeavesNoPartialState() throws Exception { + ensureGroup("atomic-team", "atomuser"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"atomic-role\",\"membership-group\":\"atomic-team\",\"supervisor\":\"admin\",\"description\":\"original\"}", 201); + + // a new description arrives with an invalid schedule; nothing may apply + sendData(PUT, MediaType.APPLICATION_JSON, "/on-call-roles/atomic-role", + "{\"name\":\"atomic-role\",\"description\":\"changed\",\"schedule\":[" + + "{\"user\":\"atomuser\",\"type\":\"nonsense\",\"time\":[{\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}]}", 400); + + final JSONObject after = new JSONObject(getJson("/on-call-roles/atomic-role", 200)); + assertEquals("original", after.getString("description")); + assertEquals("original", m_groupManager.getRole("atomic-role").getDescription().orElse(null)); + + sendRequest(DELETE, "/on-call-roles/atomic-role", 204); + } + + @Test + public void testBodyPathNameMismatchRejected() throws Exception { + ensureGroup("mm-team"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"mm-role\",\"membership-group\":\"mm-team\",\"supervisor\":\"admin\"}", 201); + sendData(PUT, MediaType.APPLICATION_JSON, "/on-call-roles/mm-role", + "{\"name\":\"other-role\",\"description\":\"x\"}", 400); + sendRequest(DELETE, "/on-call-roles/mm-role", 204); + } + + @Test + public void testRename() throws Exception { + ensureGroup("ren-team"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"ren-role\",\"membership-group\":\"ren-team\",\"supervisor\":\"admin\"}", 201); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"occupied-role\",\"membership-group\":\"ren-team\",\"supervisor\":\"admin\"}", 201); + + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles/ren-role/rename", "{\"new-name\":\"occupied-role\"}", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles/ren-role/rename", "{\"new-name\":\"bad\"}", 400); + + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles/ren-role/rename", "{\"new-name\":\"renamed-role\"}", 204); + sendRequest(GET, "/on-call-roles/ren-role", 404); + sendRequest(GET, "/on-call-roles/renamed-role", 200); + + sendRequest(DELETE, "/on-call-roles/renamed-role", 204); + sendRequest(DELETE, "/on-call-roles/occupied-role", 204); + } + + @Test + public void testCalendarShowsScheduledUserAndSupervisorGaps() throws Exception { + ensureGroup("cal-team", "caluser"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"cal-role\",\"membership-group\":\"cal-team\",\"supervisor\":\"admin\",\"schedule\":[" + + "{\"user\":\"caluser\",\"type\":\"specific\",\"time\":[{\"begins\":\"15-Jun-2093 08:00:00\",\"ends\":\"15-Jun-2093 17:00:00\"}]}]}", 201); + + final JSONObject calendar = new JSONObject(getJson("/on-call-roles/cal-role/calendar?year=2093&month=6", 200)); + assertEquals(30, calendar.getJSONArray("day").length()); + + JSONObject day15 = null, day14 = null; + for (int i = 0; i < calendar.getJSONArray("day").length(); i++) { + final JSONObject day = calendar.getJSONArray("day").getJSONObject(i); + if ("2093-06-15".equals(day.getString("date"))) day15 = day; + if ("2093-06-14".equals(day.getString("date"))) day14 = day; + } + // the scheduled interval belongs to the user, not the supervisor + boolean foundUserEntry = false; + for (int i = 0; i < day15.getJSONArray("entry").length(); i++) { + final JSONObject entry = day15.getJSONArray("entry").getJSONObject(i); + if (!entry.getBoolean("supervisor")) { + assertEquals("caluser", entry.getJSONArray("user").getString(0)); + foundUserEntry = true; + } + } + assertTrue(foundUserEntry); + // an uncovered day falls back to the supervisor + assertTrue(day14.getJSONArray("entry").length() >= 1); + assertTrue(day14.getJSONArray("entry").getJSONObject(0).getBoolean("supervisor")); + + // bad parameters + sendRequest(GET, "/on-call-roles/cal-role/calendar", 400); + sendRequest(GET, "/on-call-roles/cal-role/calendar?year=2093&month=13", 400); + sendRequest(GET, "/on-call-roles/nosuchrole/calendar?year=2093&month=6", 404); + + sendRequest(DELETE, "/on-call-roles/cal-role", 204); + } + + @Test + public void testScheduleTimesNormalizedToRuntimeWidths() throws Exception { + // BasicScheduleUtils.setOutCalTime dispatches on exact string length, + // so loosely formatted input must be stored zero-padded + ensureGroup("norm-team", "normuser"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"norm-role\",\"membership-group\":\"norm-team\",\"supervisor\":\"admin\",\"schedule\":[" + + "{\"user\":\"normuser\",\"type\":\"specific\",\"time\":[{\"begins\":\"5-Jun-2093 8:00:00\",\"ends\":\"5-Jun-2093 17:00:00\"}]}," + + "{\"user\":\"normuser\",\"type\":\"daily\",\"time\":[{\"begins\":\"8:00:00\",\"ends\":\"9:05:00\"}]}]}", 201); + final JSONObject role = new JSONObject(getJson("/on-call-roles/norm-role", 200)); + final JSONArray schedules = role.getJSONArray("schedule"); + for (int i = 0; i < schedules.length(); i++) { + final JSONObject schedule = schedules.getJSONObject(i); + final JSONObject time = schedule.getJSONArray("time").getJSONObject(0); + if ("specific".equals(schedule.getString("type"))) { + assertEquals("05-Jun-2093 08:00:00", time.getString("begins")); + } else { + assertEquals("08:00:00", time.getString("begins")); + } + } + sendRequest(DELETE, "/on-call-roles/norm-role", 204); + } + + @Test + public void testWeeklyDayCaseAndMonthlyZeroPaddingAccepted() throws Exception { + // the runtime lowercases weekday lookups and parses 01 as 1; the API + // must not be stricter than the scheduler it feeds + ensureGroup("case-team", "caseuser"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"case-role\",\"membership-group\":\"case-team\",\"supervisor\":\"admin\",\"schedule\":[" + + "{\"user\":\"caseuser\",\"type\":\"weekly\",\"time\":[{\"day\":\"Monday\",\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}," + + "{\"user\":\"caseuser\",\"type\":\"monthly\",\"time\":[{\"day\":\"01\",\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}]}", 201); + final JSONObject role = new JSONObject(getJson("/on-call-roles/case-role", 200)); + final JSONArray schedules = role.getJSONArray("schedule"); + for (int i = 0; i < schedules.length(); i++) { + final JSONObject schedule = schedules.getJSONObject(i); + if ("weekly".equals(schedule.getString("type"))) { + assertEquals("monday", schedule.getJSONArray("time").getJSONObject(0).getString("day")); + } + } + sendRequest(DELETE, "/on-call-roles/case-role", 204); + } + + @Test + public void testPartialIdentityUpdates() throws Exception { + ensureGroup("pid-team"); + ensureGroup("pid-other"); + ensureUser("pidsuper"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"pid-role\",\"membership-group\":\"pid-team\",\"supervisor\":\"admin\"}", 201); + + // each identity field is updatable on its own + sendData(PUT, MediaType.APPLICATION_JSON, "/on-call-roles/pid-role", + "{\"name\":\"pid-role\",\"supervisor\":\"pidsuper\"}", 204); + JSONObject after = new JSONObject(getJson("/on-call-roles/pid-role", 200)); + assertEquals("pidsuper", after.getString("supervisor")); + assertEquals("pid-team", after.getString("membership-group")); + + sendData(PUT, MediaType.APPLICATION_JSON, "/on-call-roles/pid-role", + "{\"name\":\"pid-role\",\"membership-group\":\"pid-other\"}", 204); + after = new JSONObject(getJson("/on-call-roles/pid-role", 200)); + assertEquals("pidsuper", after.getString("supervisor")); + assertEquals("pid-other", after.getString("membership-group")); + + // but a provided value must still be valid + sendData(PUT, MediaType.APPLICATION_JSON, "/on-call-roles/pid-role", + "{\"name\":\"pid-role\",\"supervisor\":\"nosuchuser\"}", 400); + + sendRequest(DELETE, "/on-call-roles/pid-role", 204); + } + + @Test + public void testHandEditedTimeIdRoundTrips() throws Exception { + ensureGroup("id-team", "iduser"); + final Role role = new Role(); + role.setName("id-role"); + role.setMembershipGroup("id-team"); + role.setSupervisor("admin"); + final Schedule schedule = new Schedule(); + schedule.setName("iduser"); + schedule.setType("weekly"); + final Time time = new Time(); + time.setId("hand-made-id"); + time.setDay("friday"); + time.setBegins("08:00:00"); + time.setEnds("17:00:00"); + schedule.getTimes().add(time); + role.getSchedules().add(schedule); + m_groupManager.saveRole(role); + + // the id is exposed on read and survives a schedule-list update that + // sends the entry back plus a new one + final JSONObject read = new JSONObject(getJson("/on-call-roles/id-role", 200)); + assertEquals("hand-made-id", + read.getJSONArray("schedule").getJSONObject(0).getJSONArray("time").getJSONObject(0).getString("id")); + sendData(PUT, MediaType.APPLICATION_JSON, "/on-call-roles/id-role", + "{\"name\":\"id-role\",\"schedule\":[" + + "{\"user\":\"iduser\",\"type\":\"weekly\",\"time\":[{\"id\":\"hand-made-id\",\"day\":\"friday\",\"begins\":\"08:00:00\",\"ends\":\"17:00:00\"}]}," + + "{\"user\":\"iduser\",\"type\":\"daily\",\"time\":[{\"begins\":\"18:00:00\",\"ends\":\"19:00:00\"}]}]}", 204); + assertEquals("hand-made-id", + m_groupManager.getRole("id-role").getSchedules().get(0).getTimes().get(0).getId().orElse(null)); + + sendRequest(DELETE, "/on-call-roles/id-role", 204); + } + + @Test + public void testCalendarReportsServerTimeZone() throws Exception { + ensureGroup("tz-team"); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", + "{\"name\":\"tz-role\",\"membership-group\":\"tz-team\",\"supervisor\":\"admin\"}", 201); + final JSONObject calendar = new JSONObject(getJson("/on-call-roles/tz-role/calendar?year=2093&month=6", 200)); + assertEquals(java.time.ZoneId.systemDefault().getId(), calendar.getString("time-zone")); + sendRequest(DELETE, "/on-call-roles/tz-role", 204); + } + + @Test + public void testForbiddenForNonAdmin() throws Exception { + setUser("nobody", new String[]{ "ROLE_USER" }); + try { + sendRequest(GET, "/on-call-roles", 403); + sendData(POST, MediaType.APPLICATION_JSON, "/on-call-roles", "{\"name\":\"x\"}", 403); + sendRequest(DELETE, "/on-call-roles/anything", 403); + } finally { + setUser("admin", new String[]{ "ROLE_ADMIN" }); + } + } + + private String getJson(final String url, final int expectedStatus) throws Exception { + final MockHttpServletRequest request = createRequest(GET, url); + request.addHeader("Accept", MediaType.APPLICATION_JSON); + return sendRequest(request, expectedStatus); + } +} diff --git a/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml b/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml index 4829fe6357f7..aa75da6d24fd 100644 --- a/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml +++ b/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml @@ -193,6 +193,13 @@ + + + + + + + diff --git a/ui/src/components/ManageOnCallRoles/RoleCalendarDialog.vue b/ui/src/components/ManageOnCallRoles/RoleCalendarDialog.vue new file mode 100644 index 000000000000..5bf6e493c031 --- /dev/null +++ b/ui/src/components/ManageOnCallRoles/RoleCalendarDialog.vue @@ -0,0 +1,519 @@ + + + + + diff --git a/ui/src/components/ManageOnCallRoles/RoleEditorDialog.vue b/ui/src/components/ManageOnCallRoles/RoleEditorDialog.vue new file mode 100644 index 000000000000..54a083faed7d --- /dev/null +++ b/ui/src/components/ManageOnCallRoles/RoleEditorDialog.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/ui/src/components/ManageOnCallRoles/RoleRenameDialog.vue b/ui/src/components/ManageOnCallRoles/RoleRenameDialog.vue new file mode 100644 index 000000000000..a4504c006f6b --- /dev/null +++ b/ui/src/components/ManageOnCallRoles/RoleRenameDialog.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/ui/src/components/ManageOnCallRoles/RolesHelpPanel.vue b/ui/src/components/ManageOnCallRoles/RolesHelpPanel.vue new file mode 100644 index 000000000000..c8c76bda9314 --- /dev/null +++ b/ui/src/components/ManageOnCallRoles/RolesHelpPanel.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/ui/src/components/ManageOnCallRoles/RolesTable.vue b/ui/src/components/ManageOnCallRoles/RolesTable.vue new file mode 100644 index 000000000000..bb66bb7ecd71 --- /dev/null +++ b/ui/src/components/ManageOnCallRoles/RolesTable.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/ui/src/containers/ManageOnCallRoles.vue b/ui/src/containers/ManageOnCallRoles.vue new file mode 100644 index 000000000000..24e865c23171 --- /dev/null +++ b/ui/src/containers/ManageOnCallRoles.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/ui/src/lib/adminValidation.ts b/ui/src/lib/adminValidation.ts new file mode 100644 index 000000000000..c99e0cf35c86 --- /dev/null +++ b/ui/src/lib/adminValidation.ts @@ -0,0 +1,80 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +// Client-side mirrors of the /api/v2 admin validation rules so forms can flag +// problems before submitting. These must stay in sync with UsersRestService, +// GroupsRestService and OnCallRolesRestService (INVALID_NAME/INVALID_COMMENTS). + +const INVALID_NAME = /[&<>"`':/\\%?#\s]/ +const INVALID_COMMENTS = /[&<>"`']/ +const EMAIL_SHAPE = /[^\s@]+@[^\s@]+/ + +/** + * Validates a user-id, group name or on-call role name. + * Returns a problem description, or null when the value is acceptable. + * Emptiness is not checked here; required-ness is a per-form concern. + */ +export const validateAdminName = (value: string, label: string): string | null => { + const trimmed = value.trim() + if (!trimmed) { + return null + } + if (INVALID_NAME.test(trimmed)) { + return `The ${label} must not contain markup, whitespace, or the characters : / \\ % ? #` + } + if (trimmed === '.' || trimmed === '..') { + return `The ${label} must not be a dot segment.` + } + return null +} + +/** Group comments may not contain HTML markup characters. */ +export const validateAdminComments = (value: string): string | null => { + if (value && INVALID_COMMENTS.test(value)) { + return 'The comments must not contain the characters & < > " ` \'' + } + return null +} + +/** + * Names containing / \ or % cannot be addressed as a URL path segment (the + * security filter rejects their encoded forms), so per-item API operations + * are unavailable for such hand-edited legacy entries. + */ +export const isPathAddressable = (name: string): boolean => !/[/\\%]/.test(name) + +/** + * Loose shape check: every comma-separated recipient must contain a + * local@domain somewhere, which also accepts RFC-5322 display-name forms + * like `Bill Smith `. + */ +export const validateEmailShape = (value: string, label: string): string | null => { + const trimmed = value.trim() + if (!trimmed) { + return null + } + const parts = trimmed.split(',').map((part) => part.trim()) + if (parts.some((part) => !part || !EMAIL_SHAPE.test(part))) { + return `The ${label} must look like an email address (name@domain).` + } + return null +} diff --git a/ui/src/main/router/index.ts b/ui/src/main/router/index.ts index 52a1b1b07ba4..8f33859eda8e 100644 --- a/ui/src/main/router/index.ts +++ b/ui/src/main/router/index.ts @@ -158,6 +158,25 @@ const router = createRouter({ } } }, + { + path: '/admin/oncall-roles', + name: 'Manage On-Call Roles', + component: () => import('@/containers/ManageOnCallRoles.vue'), + beforeEnter: (to, from) => { + const checkRoles = () => { + if (!adminRole.value) { + showSnackBar({ msg: 'Must be admin to manage on-call roles.' }) + router.push(from.path) + } + } + + if (rolesAreLoaded.value) { + checkRoles() + } else { + whenever(rolesAreLoaded, () => checkRoles()) + } + } + }, { path: '/map', name: 'Map', diff --git a/ui/src/services/index.ts b/ui/src/services/index.ts index 8b8c60f273ea..60e11697ea56 100644 --- a/ui/src/services/index.ts +++ b/ui/src/services/index.ts @@ -74,6 +74,17 @@ import { setUsageStatisticsStatus } from './usageStatisticsService' import { addZenithRegistration, getZenithRegistrations } from './zenithConnectService' +import { + createOnCallRole, + deleteOnCallRole, + getOnCallCalendar, + getOnCallGroupCandidates, + getOnCallRole, + getOnCallSupervisorCandidates, + listOnCallRoles, + renameOnCallRole, + updateOnCallRole +} from './onCallRoleAdminService' export default { search, @@ -135,5 +146,14 @@ export default { setUsageStatisticsStatus, addZenithRegistration, getZenithRegistrations, + createOnCallRole, + deleteOnCallRole, + getOnCallCalendar, + getOnCallGroupCandidates, + getOnCallRole, + getOnCallSupervisorCandidates, + listOnCallRoles, + renameOnCallRole, + updateOnCallRole, performLogout } diff --git a/ui/src/services/onCallRoleAdminService.ts b/ui/src/services/onCallRoleAdminService.ts new file mode 100644 index 000000000000..b37dfff12eff --- /dev/null +++ b/ui/src/services/onCallRoleAdminService.ts @@ -0,0 +1,177 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +import useSnackbar from '@/composables/useSnackbar' +import useSpinner from '@/composables/useSpinner' +import { OnCallCalendar, OnCallRole } from '@/types/onCallRoleAdmin' +import { rest, v2 } from './axiosInstances' + +const { showSnackBar } = useSnackbar() +const { startSpinner, stopSpinner } = useSpinner() +const endpoint = '/on-call-roles' + +const errorMessage = (err: any, fallback: string): string => { + const detail = err?.response?.data + return typeof detail === 'string' && detail ? detail : fallback +} + +// null on failure (not []) so callers can keep showing the previous list +const listOnCallRoles = async (): Promise => { + try { + startSpinner() + const resp = await v2.get(endpoint) + return Array.isArray(resp.data) ? resp.data : [] + } catch (_err) { + showSnackBar({ msg: 'Failed to load on-call roles.' }) + return null + } finally { + stopSpinner() + } +} + +const getOnCallRole = async (name: string): Promise => { + try { + startSpinner() + const resp = await v2.get(`${endpoint}/${encodeURIComponent(name)}`) + return resp.data ?? null + } catch (_err) { + showSnackBar({ msg: `Failed to load on-call role '${name}'.` }) + return null + } finally { + stopSpinner() + } +} + +const getOnCallCalendar = async (name: string, year: number, month: number): Promise => { + try { + startSpinner() + const resp = await v2.get(`${endpoint}/${encodeURIComponent(name)}/calendar?year=${year}&month=${month}`) + return resp.data ?? null + } catch (_err) { + showSnackBar({ msg: `Failed to load the on-call calendar for '${name}'.` }) + return null + } finally { + stopSpinner() + } +} + +const createOnCallRole = async (role: OnCallRole): Promise => { + try { + startSpinner() + await v2.post(endpoint, role) + showSnackBar({ msg: `On-call role '${role.name}' created.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to create on-call role '${role.name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const updateOnCallRole = async (role: OnCallRole): Promise => { + try { + startSpinner() + await v2.put(`${endpoint}/${encodeURIComponent(role.name)}`, role) + showSnackBar({ msg: `On-call role '${role.name}' updated.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to update on-call role '${role.name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const renameOnCallRole = async (name: string, newName: string): Promise => { + try { + startSpinner() + await v2.post(`${endpoint}/${encodeURIComponent(name)}/rename`, { 'new-name': newName }) + showSnackBar({ msg: `On-call role '${name}' renamed to '${newName}'.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to rename on-call role '${name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const deleteOnCallRole = async (name: string): Promise => { + try { + startSpinner() + await v2.delete(`${endpoint}/${encodeURIComponent(name)}`) + showSnackBar({ msg: `On-call role '${name}' deleted.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to delete on-call role '${name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +// picker options via the v1 endpoints that exist on every install +const getOnCallSupervisorCandidates = async (): Promise => { + try { + const resp = await rest.get('/users?limit=0') + const users = resp.data?.user ?? [] + return (Array.isArray(users) ? users : [users]).map((u: any) => u['user-id']).filter(Boolean) + } catch (_err) { + showSnackBar({ msg: 'Failed to load users.' }) + return [] + } +} + +const getOnCallGroupCandidates = async (): Promise> => { + try { + const resp = await rest.get('/groups?limit=0') + const groups = resp.data?.group ?? [] + const result: Record = {} + for (const group of Array.isArray(groups) ? groups : [groups]) { + if (group?.name) { + const users = group.user ?? [] + result[group.name] = Array.isArray(users) ? users : [users] + } + } + return result + } catch (_err) { + showSnackBar({ msg: 'Failed to load groups.' }) + return {} + } +} + +export { + createOnCallRole, + deleteOnCallRole, + getOnCallCalendar, + getOnCallGroupCandidates, + getOnCallRole, + getOnCallSupervisorCandidates, + listOnCallRoles, + renameOnCallRole, + updateOnCallRole +} diff --git a/ui/src/stores/onCallRoleAdminStore.ts b/ui/src/stores/onCallRoleAdminStore.ts new file mode 100644 index 000000000000..9b37e72e318f --- /dev/null +++ b/ui/src/stores/onCallRoleAdminStore.ts @@ -0,0 +1,104 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +import API from '@/services' +import { OnCallCalendar, OnCallRole } from '@/types/onCallRoleAdmin' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useOnCallRoleAdminStore = defineStore('onCallRoleAdminStore', () => { + const roles = ref([] as OnCallRole[]) + const supervisorCandidates = ref([] as string[]) + const groupMembers = ref({} as Record) + + const getRoles = async () => { + const result = await API.listOnCallRoles() + if (result !== null) { + roles.value = result + } + } + + const getPickerData = async () => { + const [users, groups] = await Promise.all([API.getOnCallSupervisorCandidates(), API.getOnCallGroupCandidates()]) + supervisorCandidates.value = users + groupMembers.value = groups + } + + const getRole = async (name: string): Promise => { + return await API.getOnCallRole(name) + } + + const getCalendar = async (name: string, year: number, month: number): Promise => { + return await API.getOnCallCalendar(name, year, month) + } + + const createRole = async (role: OnCallRole) => { + const error = await API.createOnCallRole(role) + if (error === null) { + await getRoles() + } + return error + } + + const updateRole = async (role: OnCallRole) => { + const error = await API.updateOnCallRole(role) + if (error === null) { + await getRoles() + } + return error + } + + const renameRole = async (name: string, newName: string) => { + const error = await API.renameOnCallRole(name, newName) + if (error === null) { + await getRoles() + } + return error + } + + const deleteRole = async (name: string) => { + const error = await API.deleteOnCallRole(name) + if (error === null) { + await getRoles() + } + return error + } + + const populate = async () => { + await Promise.all([getRoles(), getPickerData()]) + } + + return { + roles, + supervisorCandidates, + groupMembers, + getRoles, + getPickerData, + getRole, + getCalendar, + createRole, + updateRole, + renameRole, + deleteRole, + populate + } +}) diff --git a/ui/src/types/onCallRoleAdmin.ts b/ui/src/types/onCallRoleAdmin.ts new file mode 100644 index 000000000000..d3ecabbefd74 --- /dev/null +++ b/ui/src/types/onCallRoleAdmin.ts @@ -0,0 +1,80 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +// Wire shapes of the v2 on-call role API (/api/v2/on-call-roles). Schedule +// times use the groups.xml formats: specific entries carry full +// 'dd-MMM-yyyy HH:mm:ss' timestamps (English month abbreviations), the other +// types carry 'HH:mm:ss' with an optional day. + +export type OnCallScheduleType = 'specific' | 'daily' | 'weekly' | 'monthly' + +export interface OnCallTime { + id?: string + day?: string | null + begins: string + ends: string +} + +export interface OnCallSchedule { + user: string + type: OnCallScheduleType + time: OnCallTime[] +} + +export interface OnCallRole { + name: string + 'membership-group'?: string | null + supervisor?: string | null + description?: string | null + schedule?: OnCallSchedule[] + 'currently-on-call'?: string[] + 'schedule-error'?: string +} + +export interface OnCallCalendarEntry { + start: number + end: number + user?: string[] + supervisor: boolean +} + +export interface OnCallCalendarDay { + date: string + entry?: OnCallCalendarEntry[] +} + +export interface OnCallCalendar { + role: string + year: number + month: number + 'time-zone'?: string + day: OnCallCalendarDay[] +} + +const MONTHS_EN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + +// groups.xml stores specific times as dd-MMM-yyyy HH:mm:ss with English +// month abbreviations regardless of browser locale. +export const formatScheduleTimestamp = (date: Date): string => { + const pad = (n: number) => String(n).padStart(2, '0') + return `${pad(date.getDate())}-${MONTHS_EN[date.getMonth()]}-${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +} diff --git a/ui/tests/components/AdminDialogs/RoleEditorDialog.test.ts b/ui/tests/components/AdminDialogs/RoleEditorDialog.test.ts new file mode 100644 index 000000000000..92a84028b4e8 --- /dev/null +++ b/ui/tests/components/AdminDialogs/RoleEditorDialog.test.ts @@ -0,0 +1,75 @@ +import RoleEditorDialog from '@/components/ManageOnCallRoles/RoleEditorDialog.vue' +import { useOnCallRoleAdminStore } from '@/stores/onCallRoleAdminStore' +import { flushPromises, mount, VueWrapper } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/stores/onCallRoleAdminStore') + +const DialogStub = { + name: 'Dialog', + props: ['visible', 'header', 'modal'], + template: '
' +} + +describe('RoleEditorDialog.vue', () => { + let wrapper: VueWrapper + let store: any + + const mountDialog = async (role: any = null) => { + wrapper = mount(RoleEditorDialog, { + props: { visible: false, role }, + global: { + plugins: [PrimeVue], + stubs: { Dialog: DialogStub } + } + }) + await wrapper.setProps({ visible: true }) + await flushPromises() + } + + beforeEach(() => { + vi.clearAllMocks() + store = { + supervisorCandidates: ['admin', 'jose'], + groupMembers: { NOC: ['jose'] }, + createRole: vi.fn().mockResolvedValue(null), + updateRole: vi.fn().mockResolvedValue(null) + } + vi.mocked(useOnCallRoleAdminStore).mockReturnValue(store) + }) + + it('flags a role name with whitespace and disables saving', async () => { + await mountDialog() + await wrapper.find('[data-test="role-name-input"]').setValue('NOC Duty') + + expect(wrapper.find('[data-test="name-error"]').text()).toContain('must not contain') + expect(wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + }) + + it('requires a membership group and supervisor before saving', async () => { + await mountDialog() + await wrapper.find('[data-test="role-name-input"]').setValue('NOC-Duty') + + expect(wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + }) + + it('shows a server rejection inside the dialog and stays open', async () => { + store.updateRole.mockResolvedValue('A supervisor is required and must be an existing user.') + await mountDialog({ name: 'NOC-Duty', 'membership-group': 'NOC', supervisor: 'admin' }) + await wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-test="dialog-error"]').text()).toContain('supervisor is required') + expect(wrapper.emitted('update:visible') ?? []).toEqual([]) + }) + + it('saves an edit and closes', async () => { + await mountDialog({ name: 'NOC-Duty', 'membership-group': 'NOC', supervisor: 'admin', description: 'x' }) + await wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + + expect(store.updateRole).toHaveBeenCalledWith(expect.objectContaining({ name: 'NOC-Duty' })) + expect(wrapper.emitted('update:visible')?.at(-1)).toEqual([false]) + }) +}) diff --git a/ui/tests/lib/adminValidation.test.ts b/ui/tests/lib/adminValidation.test.ts new file mode 100644 index 000000000000..04062da50be5 --- /dev/null +++ b/ui/tests/lib/adminValidation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + isPathAddressable, + validateAdminComments, + validateAdminName, + validateEmailShape +} from '@/lib/adminValidation' + +describe('validateAdminName', () => { + it('accepts ordinary names and empty values', () => { + expect(validateAdminName('NOC-Duty_1', 'group name')).toBeNull() + expect(validateAdminName('', 'group name')).toBeNull() + expect(validateAdminName(' ', 'group name')).toBeNull() + }) + + it('rejects whitespace, markup and URL-hostile characters', () => { + for (const bad of ['Test Group', 'a { + expect(validateAdminName('.', 'user-id')).toContain('dot segment') + expect(validateAdminName('..', 'user-id')).toContain('dot segment') + }) + + it('names the field in the message', () => { + expect(validateAdminName('a b', 'role name')).toContain('role name') + }) +}) + +describe('validateAdminComments', () => { + it('accepts plain text and empty values', () => { + expect(validateAdminComments('The administrators, on shift 24/7.')).toBeNull() + expect(validateAdminComments('')).toBeNull() + }) + + it('rejects markup characters', () => { + for (const bad of ['x', 'a & b', 'quote "x"', "it's", 'tick `x`']) { + expect(validateAdminComments(bad), bad).not.toBeNull() + } + }) +}) + +describe('validateEmailShape', () => { + it('accepts empty values and common deliverable forms', () => { + expect(validateEmailShape('', 'email')).toBeNull() + expect(validateEmailShape('noc@example.org', 'email')).toBeNull() + expect(validateEmailShape('Bill Smith ', 'email')).toBeNull() + expect(validateEmailShape('a@example.com, b@example.com', 'email')).toBeNull() + }) + + it('rejects values without a local@domain part', () => { + expect(validateEmailShape('not-an-email', 'email')).toContain('email') + expect(validateEmailShape('a@', 'email')).not.toBeNull() + expect(validateEmailShape('a@example.com,,b@example.com', 'pager email')).toContain('pager email') + }) +}) + +describe('isPathAddressable', () => { + it('allows ordinary names', () => { + expect(isPathAddressable('NOC-Duty')).toBe(true) + expect(isPathAddressable('Some Group')).toBe(true) + }) + + it('flags names the security filter cannot address as path segments', () => { + expect(isPathAddressable('NOC/Primary')).toBe(false) + expect(isPathAddressable('a\\b')).toBe(false) + expect(isPathAddressable('a%b')).toBe(false) + }) +}) diff --git a/ui/tests/stores/onCallRoleAdminStore.test.ts b/ui/tests/stores/onCallRoleAdminStore.test.ts new file mode 100644 index 000000000000..ddfef9eebe5e --- /dev/null +++ b/ui/tests/stores/onCallRoleAdminStore.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useOnCallRoleAdminStore } from '@/stores/onCallRoleAdminStore' +import API from '@/services' +import { OnCallRole } from '@/types/onCallRoleAdmin' +import { formatScheduleTimestamp } from '@/types/onCallRoleAdmin' + +vi.mock('@/services', () => ({ + default: { + listOnCallRoles: vi.fn(), + getOnCallRole: vi.fn(), + getOnCallCalendar: vi.fn(), + getOnCallSupervisorCandidates: vi.fn(), + getOnCallGroupCandidates: vi.fn(), + createOnCallRole: vi.fn(), + updateOnCallRole: vi.fn(), + renameOnCallRole: vi.fn(), + deleteOnCallRole: vi.fn() + } +})) + +describe('useOnCallRoleAdminStore', () => { + let store: ReturnType + + const mockRoles: OnCallRole[] = [ + { name: 'NOC-Duty', 'membership-group': 'NOC', supervisor: 'admin', 'currently-on-call': ['first'] } + ] + + beforeEach(() => { + setActivePinia(createPinia()) + store = useOnCallRoleAdminStore() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should start empty', () => { + expect(store.roles).toEqual([]) + expect(store.supervisorCandidates).toEqual([]) + expect(store.groupMembers).toEqual({}) + }) + + it('populate should load roles and picker data', async () => { + vi.mocked(API.listOnCallRoles).mockResolvedValue(mockRoles) + vi.mocked(API.getOnCallSupervisorCandidates).mockResolvedValue(['admin', 'first']) + vi.mocked(API.getOnCallGroupCandidates).mockResolvedValue({ NOC: ['first'] }) + + await store.populate() + + expect(store.roles).toEqual(mockRoles) + expect(store.supervisorCandidates).toEqual(['admin', 'first']) + expect(store.groupMembers).toEqual({ NOC: ['first'] }) + }) + + it('a failed refresh should keep the previous role list', async () => { + vi.mocked(API.listOnCallRoles).mockResolvedValue(mockRoles) + await store.getRoles() + expect(store.roles).toEqual(mockRoles) + + vi.mocked(API.listOnCallRoles).mockResolvedValue(null) + await store.getRoles() + expect(store.roles).toEqual(mockRoles) + }) + + it('createRole should refresh on success and not on failure', async () => { + vi.mocked(API.createOnCallRole).mockResolvedValue(null) + vi.mocked(API.listOnCallRoles).mockResolvedValue(mockRoles) + expect(await store.createRole({ name: 'NOC-Duty' })).toBe(null) + expect(API.listOnCallRoles).toHaveBeenCalledTimes(1) + + vi.clearAllMocks() + vi.mocked(API.createOnCallRole).mockResolvedValue('it failed') + expect(await store.createRole({ name: 'NOC-Duty' })).toBe('it failed') + expect(API.listOnCallRoles).not.toHaveBeenCalled() + }) + + it('updateRole should pass the payload through and refresh', async () => { + vi.mocked(API.updateOnCallRole).mockResolvedValue(null) + vi.mocked(API.listOnCallRoles).mockResolvedValue(mockRoles) + + const payload: OnCallRole = { name: 'NOC-Duty', schedule: [{ user: 'first', type: 'specific', time: [{ begins: 'a', ends: 'b' }] }] } + await store.updateRole(payload) + + expect(API.updateOnCallRole).toHaveBeenCalledWith(payload) + expect(API.listOnCallRoles).toHaveBeenCalledTimes(1) + }) + + it('renameRole should pass old and new names and refresh', async () => { + vi.mocked(API.renameOnCallRole).mockResolvedValue(null) + vi.mocked(API.listOnCallRoles).mockResolvedValue(mockRoles) + + await store.renameRole('NOC-Duty', 'NOC-Rota') + + expect(API.renameOnCallRole).toHaveBeenCalledWith('NOC-Duty', 'NOC-Rota') + }) + + it('deleteRole should refresh on success', async () => { + vi.mocked(API.deleteOnCallRole).mockResolvedValue(null) + vi.mocked(API.listOnCallRoles).mockResolvedValue([]) + + await store.deleteRole('NOC-Duty') + + expect(store.roles).toEqual([]) + }) + + it('getCalendar should pass parameters through', async () => { + const calendar = { role: 'NOC-Duty', year: 2093, month: 6, day: [] } + vi.mocked(API.getOnCallCalendar).mockResolvedValue(calendar) + + expect(await store.getCalendar('NOC-Duty', 2093, 6)).toEqual(calendar) + expect(API.getOnCallCalendar).toHaveBeenCalledWith('NOC-Duty', 2093, 6) + }) +}) + +describe('formatScheduleTimestamp', () => { + it('formats with English month abbreviations regardless of locale', () => { + expect(formatScheduleTimestamp(new Date(2093, 5, 15, 8, 0, 0))).toBe('15-Jun-2093 08:00:00') + expect(formatScheduleTimestamp(new Date(2093, 11, 1, 17, 30, 5))).toBe('01-Dec-2093 17:30:05') + }) +})