diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java new file mode 100644 index 0000000000..6c8915e83f --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java @@ -0,0 +1,229 @@ +package com.openframe.api.service.device; + +import com.openframe.api.exception.DeviceNotFoundException; +import com.openframe.core.exception.ConflictException; +import com.openframe.data.document.tag.Tag; +import com.openframe.data.document.tag.TagAssignment; +import com.openframe.data.document.tag.TagValidation; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.tag.TagAssignmentRepository; +import com.openframe.data.repository.tag.TagRepository; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; + +import static com.openframe.data.document.tag.TagEntityType.DEVICE; + +/** + * Attaches and detaches DEVICE tags at runtime — the counterpart to + * {@code RegistrationTagAssignmentService}, which does the same thing from the agent-registration + * payload. Both write only Mongo ({@code tags} + {@code tag_assignments}); the Pinot facet columns + * are refreshed by {@code MachineTagEventAspect}, which intercepts the repository calls made here + * and republishes the machine's full tag list to Kafka. + * + *

Every write therefore has to go through an intercepted repository method — {@code save} or + * {@code deleteByEntityIdAndTagIdAndEntityType}. Bypassing them (e.g. {@code deleteAll}) would + * leave the device's Pinot row carrying tags it no longer has. + * + *

Deliberately not {@code @Transactional}: no Mongo transaction manager is configured, so the + * annotation would promise atomicity it cannot give. The tag key is written before the assignment, + * so a failure in between leaves at worst an unused key. + * + *

Not reused from the client-core service because {@code openframe-client-core} does not depend + * on {@code openframe-api-lib}, and pulling the logic down into a shared module would drag the + * machine/tag repositories along with it. + */ +@Service +@Slf4j +@Validated +@RequiredArgsConstructor +public class DeviceTagService { + + private final TagRepository tagRepository; + private final TagAssignmentRepository tagAssignmentRepository; + private final MachineRepository machineRepository; + + /** + * Tags a device with {@code key}, creating the tag key on first use. + * + *

Additive in both directions: new {@code values} are merged into the device's existing + * assignment rather than replacing it, and into the tag's list of predefined options. Calling + * it twice with the same key and values is a no-op beyond the Pinot republish. + * + * @return the tag, carrying this device's values (not the tag's full option list) + */ + public Tag assignTag(@NotBlank String machineId, @NotBlank String key, List values) { + return writeTag(machineId, key, values, false); + } + + /** + * Sets the device's values for {@code key} to exactly {@code values}, creating the tag key and + * the assignment on first use. This is how a single value is taken off a device: send the list + * without it. {@code null} or empty leaves the device carrying the key as a plain label; use + * {@link #removeTag} to detach the key altogether. + * + *

Only the device's assignment is replaced. The tag's predefined options stay additive — a + * value dropped here may still be in use on other devices. + * + * @return the tag, carrying this device's values (not the tag's full option list) + */ + public Tag setTagValues(@NotBlank String machineId, @NotBlank String key, List values) { + return writeTag(machineId, key, values, true); + } + + private Tag writeTag(String machineId, String key, List values, boolean replace) { + TagValidation.validateKey(key); + TagValidation.validateValues(values, key); + requireMachine(machineId); + + Tag tag = findOrCreateTag(key, values); + List assignedValues = upsertAssignment(machineId, tag.getId(), values, replace); + + log.info("{} tag '{}' on machine {}, values now {}", replace ? "Set" : "Assigned", key, machineId, assignedValues); + return Tag.builder() + .id(tag.getId()) + .key(tag.getKey()) + .description(tag.getDescription()) + .color(tag.getColor()) + .values(assignedValues) + .entityType(tag.getEntityType()) + .createdAt(tag.getCreatedAt()) + .build(); + } + + /** + * Detaches a tag from a device. The tag key itself survives — it stays available for other + * devices and in the filter dropdowns; use {@code TagService.deleteTag} to drop the key + * everywhere. + * + * @return {@code true} if the device had the tag, {@code false} if there was nothing to remove + */ + public boolean removeTag(@NotBlank String machineId, @NotBlank String tagId) { + requireMachine(machineId); + + boolean assigned = tagAssignmentRepository + .findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE) + .isPresent(); + if (!assigned) { + log.info("Tag {} is not assigned to machine {}, nothing to remove", tagId, machineId); + return false; + } + + // Aspect-intercepted: publishes the machine's remaining tags before the delete proceeds. + tagAssignmentRepository.deleteByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE); + log.info("Removed tag {} from machine {}", tagId, machineId); + return true; + } + + private void requireMachine(String machineId) { + if (machineRepository.findByMachineId(machineId).isEmpty()) { + throw new DeviceNotFoundException("Device not found: " + machineId); + } + } + + /** + * Finds the DEVICE tag for {@code key}, or creates it. On an existing tag any previously unseen + * values are appended to its predefined options, so a value typed on one device becomes a + * suggestion for the next. + * + *

Keys are unique ignoring case, same as {@code TagService.createTag}: the Mongo index is + * case-sensitive, so without the check "Site" would quietly become a second key next to "site". + */ + private Tag findOrCreateTag(String key, List values) { + Tag existing = tagRepository.findByKeyAndEntityType(key, DEVICE); + if (existing == null) { + if (tagRepository.existsByKeyIgnoreCaseAndEntityType(key, DEVICE)) { + throw new ConflictException("Tag with key '" + key + "' already exists"); + } + try { + Tag created = tagRepository.save(Tag.builder() + .key(key) + .values(merge(null, values)) + .entityType(DEVICE) + .createdAt(Instant.now()) + .build()); + log.info("Created DEVICE tag '{}' (id={})", key, created.getId()); + return created; + } catch (DuplicateKeyException e) { + // Lost a first-use race to another request: the unique (tenant, key, entityType) + // index kept the key single, so carry on with the winner's tag. + existing = tagRepository.findByKeyAndEntityType(key, DEVICE); + if (existing == null) { + throw e; + } + } + } + + List merged = merge(existing.getValues(), values); + if (merged.size() != size(existing.getValues())) { + existing.setValues(merged); + // Aspect-intercepted: refreshes every device already carrying this tag. + existing = tagRepository.save(existing); + log.info("Appended values {} to existing tag '{}'", values, key); + } + return existing; + } + + /** + * Writes {@code values} to the device's assignment, creating it if the device does not carry the + * tag yet: merged into the current values, or in place of them when {@code replace} is set. + * Saving through the repository is what triggers the Pinot republish, so an unchanged list is + * not saved at all. + */ + private List upsertAssignment(String machineId, String tagId, List values, boolean replace) { + Optional existing = tagAssignmentRepository + .findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE); + + if (existing.isEmpty()) { + try { + TagAssignment saved = tagAssignmentRepository.save(TagAssignment.builder() + .entityId(machineId) + .tagId(tagId) + .entityType(DEVICE) + .values(merge(null, values)) + .taggedAt(Instant.now()) + .build()); + return saved.getValues(); + } catch (DuplicateKeyException e) { + // Same race on the unique (tenant, entity, tag, entityType) index: apply the values + // to the assignment the other request just created. + existing = tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE); + if (existing.isEmpty()) { + throw e; + } + } + } + + TagAssignment assignment = existing.get(); + List updated = merge(replace ? null : assignment.getValues(), values); + if (!updated.equals(normalize(assignment.getValues()))) { + assignment.setValues(updated); + return tagAssignmentRepository.save(assignment).getValues(); + } + return normalize(assignment.getValues()); + } + + /** Insertion-ordered union — existing values keep their order, new ones are appended. */ + private static List merge(List current, List added) { + LinkedHashSet merged = new LinkedHashSet<>(normalize(current)); + merged.addAll(normalize(added)); + return new ArrayList<>(merged); + } + + private static List normalize(List values) { + return values != null ? values : List.of(); + } + + private static int size(List values) { + return values != null ? values.size() : 0; + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/device/DeviceTagServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/device/DeviceTagServiceTest.java new file mode 100644 index 0000000000..9322fce4c3 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/device/DeviceTagServiceTest.java @@ -0,0 +1,435 @@ +package com.openframe.api.service.device; + +import com.openframe.api.exception.DeviceNotFoundException; +import com.openframe.core.exception.ConflictException; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.tag.Tag; +import com.openframe.data.document.tag.TagAssignment; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.tag.TagAssignmentRepository; +import com.openframe.data.repository.tag.TagRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.dao.DuplicateKeyException; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static com.openframe.data.document.tag.TagEntityType.DEVICE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DeviceTagServiceTest { + + private static final String MACHINE_ID = "machine-1"; + private static final String TAG_ID = "tag-1"; + + @Mock private TagRepository tagRepository; + @Mock private TagAssignmentRepository tagAssignmentRepository; + @Mock private MachineRepository machineRepository; + + private DeviceTagService service; + + @BeforeEach + void setUp() { + service = new DeviceTagService(tagRepository, tagAssignmentRepository, machineRepository); + + when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(new Machine())); + when(tagRepository.save(any())).thenAnswer(inv -> { + Tag tag = inv.getArgument(0); + if (tag.getId() == null) { + tag.setId(TAG_ID); + } + return tag; + }); + when(tagAssignmentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE)) + .thenReturn(Optional.empty()); + } + + // ---- assignTag ---- + + @Test + void assignTagCreatesKeyAndAssignmentOnFirstUse() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)).thenReturn(null); + + Tag result = service.assignTag(MACHINE_ID, "site", List.of("chicago")); + + ArgumentCaptor tag = ArgumentCaptor.forClass(Tag.class); + verify(tagRepository).save(tag.capture()); + assertThat(tag.getValue().getKey()).isEqualTo("site"); + assertThat(tag.getValue().getEntityType()).isEqualTo(DEVICE); + assertThat(tag.getValue().getValues()).containsExactly("chicago"); + assertThat(tag.getValue().getCreatedAt()).isNotNull(); + + ArgumentCaptor assignment = ArgumentCaptor.forClass(TagAssignment.class); + verify(tagAssignmentRepository).save(assignment.capture()); + assertThat(assignment.getValue().getEntityId()).isEqualTo(MACHINE_ID); + assertThat(assignment.getValue().getTagId()).isEqualTo(TAG_ID); + assertThat(assignment.getValue().getEntityType()).isEqualTo(DEVICE); + assertThat(assignment.getValue().getValues()).containsExactly("chicago"); + assertThat(assignment.getValue().getTaggedAt()).isNotNull(); + + assertThat(result.getId()).isEqualTo(TAG_ID); + assertThat(result.getKey()).isEqualTo("site"); + assertThat(result.getValues()).containsExactly("chicago"); + } + + @Test + void assignTagWithoutValuesStoresLabelTag() { + when(tagRepository.findByKeyAndEntityType("vip", DEVICE)).thenReturn(null); + + Tag result = service.assignTag(MACHINE_ID, "vip", null); + + ArgumentCaptor assignment = ArgumentCaptor.forClass(TagAssignment.class); + verify(tagAssignmentRepository).save(assignment.capture()); + assertThat(assignment.getValue().getValues()).isEmpty(); + assertThat(result.getValues()).isEmpty(); + } + + @Test + void assignTagRejectsCaseVariantOfExistingKey() { + when(tagRepository.findByKeyAndEntityType("Site", DEVICE)).thenReturn(null); + when(tagRepository.existsByKeyIgnoreCaseAndEntityType("Site", DEVICE)).thenReturn(true); + + assertThatThrownBy(() -> service.assignTag(MACHINE_ID, "Site", List.of("chicago"))) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("Site"); + + verify(tagRepository, never()).save(any()); + verify(tagAssignmentRepository, never()).save(any()); + } + + @Test + void assignTagReusesExistingKeyWithoutResavingItWhenValuesAreKnown() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)) + .thenReturn(existingTag("chicago", "boston")); + + service.assignTag(MACHINE_ID, "site", List.of("boston")); + + // Tag.save republishes every device carrying the key — must not fire for a no-op merge. + verify(tagRepository, never()).save(any()); + verify(tagAssignmentRepository).save(any()); + } + + @Test + void assignTagAppendsUnseenValuesToTheKeysOptionsKeepingOrder() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)) + .thenReturn(existingTag("chicago", "boston")); + + service.assignTag(MACHINE_ID, "site", List.of("boston", "austin")); + + ArgumentCaptor tag = ArgumentCaptor.forClass(Tag.class); + verify(tagRepository).save(tag.capture()); + assertThat(tag.getValue().getId()).isEqualTo(TAG_ID); + assertThat(tag.getValue().getValues()).containsExactly("chicago", "boston", "austin"); + } + + @Test + void assignTagMergesIntoTheDevicesExistingValuesInsteadOfReplacing() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)) + .thenReturn(existingTag("chicago", "boston")); + TagAssignment current = existingAssignment("chicago"); + when(tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE)) + .thenReturn(Optional.of(current)); + + Tag result = service.assignTag(MACHINE_ID, "site", List.of("boston")); + + ArgumentCaptor assignment = ArgumentCaptor.forClass(TagAssignment.class); + verify(tagAssignmentRepository).save(assignment.capture()); + assertThat(assignment.getValue().getId()).isEqualTo("assignment-1"); + assertThat(assignment.getValue().getValues()).containsExactly("chicago", "boston"); + assertThat(result.getValues()).containsExactly("chicago", "boston"); + } + + @Test + void assignTagIsIdempotentForValuesTheDeviceAlreadyCarries() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)) + .thenReturn(existingTag("chicago", "boston")); + when(tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE)) + .thenReturn(Optional.of(existingAssignment("chicago", "boston"))); + + Tag result = service.assignTag(MACHINE_ID, "site", List.of("boston", "chicago")); + + verify(tagRepository, never()).save(any()); + verify(tagAssignmentRepository, never()).save(any()); + assertThat(result.getValues()).containsExactly("chicago", "boston"); + } + + @Test + void assignTagReturnsTheDevicesValuesNotTheKeysFullOptionList() { + Tag tag = existingTag("chicago", "boston", "austin"); + tag.setDescription("Office location"); + tag.setColor("#FF8800"); + when(tagRepository.findByKeyAndEntityType("site", DEVICE)).thenReturn(tag); + + Tag result = service.assignTag(MACHINE_ID, "site", List.of("austin")); + + assertThat(result.getValues()).containsExactly("austin"); + assertThat(result.getDescription()).isEqualTo("Office location"); + assertThat(result.getColor()).isEqualTo("#FF8800"); + // The stored key must keep its full option list — the return value is a copy. + assertThat(tag.getValues()).containsExactly("chicago", "boston", "austin"); + } + + @Test + void assignTagFailsForUnknownDeviceBeforeTouchingTags() { + when(machineRepository.findByMachineId("ghost")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.assignTag("ghost", "site", List.of("chicago"))) + .isInstanceOf(DeviceNotFoundException.class) + .hasMessageContaining("ghost"); + + verifyNoInteractions(tagRepository, tagAssignmentRepository); + } + + @Test + void assignTagRejectsInvalidKeyBeforeAnyLookup() { + assertThatThrownBy(() -> service.assignTag(MACHINE_ID, "bad key!", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bad key!"); + + verifyNoInteractions(machineRepository, tagRepository, tagAssignmentRepository); + } + + @Test + void assignTagRejectsInvalidValueBeforeAnyLookup() { + assertThatThrownBy(() -> service.assignTag(MACHINE_ID, "site", List.of("chicago", "new york"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("new york"); + + verifyNoInteractions(machineRepository, tagRepository, tagAssignmentRepository); + } + + @Test + void assignTagDropsDuplicateValuesFromTheRequest() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)).thenReturn(null); + + Tag result = service.assignTag(MACHINE_ID, "site", List.of("chicago", "chicago", "boston")); + + ArgumentCaptor tag = ArgumentCaptor.forClass(Tag.class); + verify(tagRepository).save(tag.capture()); + assertThat(tag.getValue().getValues()).containsExactly("chicago", "boston"); + assertThat(result.getValues()).containsExactly("chicago", "boston"); + } + + @Test + void assignTagAdoptsTheWinnersKeyWhenAFirstUseRaceIsLost() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)) + .thenReturn(null) + .thenReturn(existingTag("chicago")); + doThrow(new DuplicateKeyException("tenant_key_entity_idx")).when(tagRepository).save(any()); + + Tag result = service.assignTag(MACHINE_ID, "site", List.of("chicago")); + + assertThat(result.getId()).isEqualTo(TAG_ID); + ArgumentCaptor assignment = ArgumentCaptor.forClass(TagAssignment.class); + verify(tagAssignmentRepository).save(assignment.capture()); + assertThat(assignment.getValue().getTagId()).isEqualTo(TAG_ID); + } + + @Test + void assignTagMergesIntoTheWinnersAssignmentWhenTheAssignmentRaceIsLost() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)) + .thenReturn(existingTag("chicago", "boston")); + when(tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(existingAssignment("chicago"))); + doThrow(new DuplicateKeyException("tenant_entity_tag_idx")) + .doAnswer(inv -> inv.getArgument(0)) + .when(tagAssignmentRepository).save(any()); + + Tag result = service.assignTag(MACHINE_ID, "site", List.of("boston")); + + assertThat(result.getValues()).containsExactly("chicago", "boston"); + } + + @Test + void assignTagRethrowsADuplicateKeyItCannotExplain() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)).thenReturn(null); + doThrow(new DuplicateKeyException("tenant_key_entity_idx")).when(tagRepository).save(any()); + + assertThatThrownBy(() -> service.assignTag(MACHINE_ID, "site", List.of("chicago"))) + .isInstanceOf(DuplicateKeyException.class); + verify(tagAssignmentRepository, never()).save(any()); + } + + // ---- setTagValues ---- + + @Test + void setTagValuesReplacesTheDevicesValuesSoASingleValueCanBeRemoved() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)) + .thenReturn(existingTag("chicago", "boston", "austin")); + when(tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE)) + .thenReturn(Optional.of(existingAssignment("chicago", "boston", "austin"))); + + Tag result = service.setTagValues(MACHINE_ID, "site", List.of("chicago", "austin")); + + ArgumentCaptor assignment = ArgumentCaptor.forClass(TagAssignment.class); + verify(tagAssignmentRepository).save(assignment.capture()); + assertThat(assignment.getValue().getId()).isEqualTo("assignment-1"); + assertThat(assignment.getValue().getValues()).containsExactly("chicago", "austin"); + assertThat(result.getValues()).containsExactly("chicago", "austin"); + } + + @Test + void setTagValuesKeepsTheDroppedValueInTheKeysOptions() { + Tag tag = existingTag("chicago", "boston"); + when(tagRepository.findByKeyAndEntityType("site", DEVICE)).thenReturn(tag); + when(tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE)) + .thenReturn(Optional.of(existingAssignment("chicago", "boston"))); + + service.setTagValues(MACHINE_ID, "site", List.of("chicago")); + + // Other devices may still carry "boston"; the key's options only ever grow. + verify(tagRepository, never()).save(any()); + assertThat(tag.getValues()).containsExactly("chicago", "boston"); + } + + @Test + void setTagValuesAppendsUnseenValuesToTheKeysOptions() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)) + .thenReturn(existingTag("chicago")); + when(tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE)) + .thenReturn(Optional.of(existingAssignment("chicago"))); + + Tag result = service.setTagValues(MACHINE_ID, "site", List.of("austin")); + + ArgumentCaptor tag = ArgumentCaptor.forClass(Tag.class); + verify(tagRepository).save(tag.capture()); + assertThat(tag.getValue().getValues()).containsExactly("chicago", "austin"); + assertThat(result.getValues()).containsExactly("austin"); + } + + @Test + void setTagValuesWithNoValuesKeepsTheKeyOnTheDeviceAsALabel() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)) + .thenReturn(existingTag("chicago")); + when(tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE)) + .thenReturn(Optional.of(existingAssignment("chicago"))); + + Tag result = service.setTagValues(MACHINE_ID, "site", null); + + ArgumentCaptor assignment = ArgumentCaptor.forClass(TagAssignment.class); + verify(tagAssignmentRepository).save(assignment.capture()); + assertThat(assignment.getValue().getValues()).isEmpty(); + assertThat(result.getValues()).isEmpty(); + verify(tagAssignmentRepository, never()).deleteByEntityIdAndTagIdAndEntityType(any(), any(), any()); + } + + @Test + void setTagValuesCreatesKeyAndAssignmentOnFirstUse() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)).thenReturn(null); + + Tag result = service.setTagValues(MACHINE_ID, "site", List.of("chicago")); + + verify(tagRepository).save(any()); + ArgumentCaptor assignment = ArgumentCaptor.forClass(TagAssignment.class); + verify(tagAssignmentRepository).save(assignment.capture()); + assertThat(assignment.getValue().getEntityId()).isEqualTo(MACHINE_ID); + assertThat(assignment.getValue().getValues()).containsExactly("chicago"); + assertThat(result.getValues()).containsExactly("chicago"); + } + + @Test + void setTagValuesSkipsTheSaveWhenNothingChanges() { + when(tagRepository.findByKeyAndEntityType("site", DEVICE)) + .thenReturn(existingTag("chicago", "boston")); + when(tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE)) + .thenReturn(Optional.of(existingAssignment("chicago", "boston"))); + + service.setTagValues(MACHINE_ID, "site", List.of("chicago", "boston")); + + verify(tagRepository, never()).save(any()); + verify(tagAssignmentRepository, never()).save(any()); + } + + @Test + void setTagValuesAppliesTheSameGuardsAsAssignTag() { + when(machineRepository.findByMachineId("ghost")).thenReturn(Optional.empty()); + when(tagRepository.findByKeyAndEntityType("Site", DEVICE)).thenReturn(null); + when(tagRepository.existsByKeyIgnoreCaseAndEntityType("Site", DEVICE)).thenReturn(true); + + assertThatThrownBy(() -> service.setTagValues("ghost", "site", List.of("chicago"))) + .isInstanceOf(DeviceNotFoundException.class); + assertThatThrownBy(() -> service.setTagValues(MACHINE_ID, "site", List.of("new york"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.setTagValues(MACHINE_ID, "Site", List.of("chicago"))) + .isInstanceOf(ConflictException.class); + + verify(tagRepository, never()).save(any()); + verify(tagAssignmentRepository, never()).save(any()); + } + + // ---- removeTag ---- + + @Test + void removeTagDeletesThroughTheAspectInterceptedMethodAndKeepsTheKey() { + when(tagAssignmentRepository.findByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE)) + .thenReturn(Optional.of(existingAssignment("chicago"))); + + boolean removed = service.removeTag(MACHINE_ID, TAG_ID); + + assertThat(removed).isTrue(); + // Only this delete is intercepted by MachineTagEventAspect; any other one skips the Pinot re-sync. + verify(tagAssignmentRepository).deleteByEntityIdAndTagIdAndEntityType(MACHINE_ID, TAG_ID, DEVICE); + verify(tagAssignmentRepository, never()).deleteByTagId(any()); + verify(tagAssignmentRepository, never()).deleteByEntityIdAndEntityType(any(), any()); + verify(tagAssignmentRepository, never()).delete(any()); + verifyNoInteractions(tagRepository); + } + + @Test + void removeTagReturnsFalseWhenTheDeviceDoesNotCarryTheTag() { + boolean removed = service.removeTag(MACHINE_ID, TAG_ID); + + assertThat(removed).isFalse(); + verify(tagAssignmentRepository, never()).deleteByEntityIdAndTagIdAndEntityType(any(), any(), any()); + } + + @Test + void removeTagFailsForUnknownDevice() { + when(machineRepository.findByMachineId("ghost")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.removeTag("ghost", TAG_ID)) + .isInstanceOf(DeviceNotFoundException.class); + + verifyNoInteractions(tagAssignmentRepository); + } + + private static Tag existingTag(String... values) { + return Tag.builder() + .id(TAG_ID) + .key("site") + .entityType(DEVICE) + .values(new ArrayList<>(List.of(values))) + .createdAt(Instant.parse("2026-01-01T00:00:00Z")) + .build(); + } + + private static TagAssignment existingAssignment(String... values) { + return TagAssignment.builder() + .id("assignment-1") + .entityId(MACHINE_ID) + .tagId(TAG_ID) + .entityType(DEVICE) + .values(new ArrayList<>(List.of(values))) + .build(); + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java index 2b5f08a930..3e82cc30b6 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java @@ -19,6 +19,7 @@ import com.openframe.api.mapper.GraphQLDeviceMapper; import com.openframe.api.service.device.DeviceFilterService; import com.openframe.api.service.device.DeviceService; +import com.openframe.api.service.device.DeviceTagService; import com.openframe.api.service.FleetVulnerabilityStatusService; import com.openframe.api.service.TagService; import com.openframe.data.document.device.Machine; @@ -53,6 +54,7 @@ public class DeviceDataFetcher { private final DeviceService deviceService; private final DeviceFilterService deviceFilterService; + private final DeviceTagService deviceTagService; private final TagService tagService; private final FleetVulnerabilityStatusService fleetVulnerabilityStatusService; private final GraphQLDeviceMapper mapper; @@ -131,6 +133,30 @@ public Machine updateDeviceNickname(@InputArgument @NotBlank String machineId, return deviceService.updateNickname(machineId, nickname); } + @DgsMutation + public Tag assignDeviceTag(@InputArgument @NotBlank String machineId, + @InputArgument @NotBlank String key, + @InputArgument List values) { + log.debug("Assigning tag '{}' to machineId: {}", key, machineId); + return deviceTagService.assignTag(machineId, key, values); + } + + @DgsMutation + public Tag setDeviceTagValues(@InputArgument @NotBlank String machineId, + @InputArgument @NotBlank String key, + @InputArgument List values) { + log.debug("Setting values of tag '{}' on machineId: {}", key, machineId); + return deviceTagService.setTagValues(machineId, key, values); + } + + @DgsMutation + public boolean removeDeviceTag(@InputArgument @NotBlank String machineId, + @InputArgument @NotBlank String tagId) { + String rawTagId = RELAY.fromGlobalId(tagId).getId(); + log.debug("Removing tag {} (rawId: {}) from machineId: {}", tagId, rawTagId, machineId); + return deviceTagService.removeTag(machineId, rawTagId); + } + @DgsData(parentType = "Machine", field = "id") public String machineNodeId(DgsDataFetchingEnvironment dfe) { Machine machine = dfe.getSource(); diff --git a/openframe-api-service-core/src/main/resources/schema/device.graphqls b/openframe-api-service-core/src/main/resources/schema/device.graphqls index b02e91312a..84f0197ce3 100644 --- a/openframe-api-service-core/src/main/resources/schema/device.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/device.graphqls @@ -146,4 +146,18 @@ type InstalledAgent implements Node { extend type Mutation { updateDeviceNickname(machineId: String!, nickname: String): Machine! + + # Tag a device, creating the tag key on first use. Values are merged into whatever the + # device already carries for that key, so this only ever adds. Returns the tag with + # THIS device's values. + assignDeviceTag(machineId: String!, key: String!, values: [String!]): Tag! + + # Set a device's values for a tag key to exactly this list, creating the key on first use. + # Send the list without a value to take that value off the device; null/empty keeps the + # key on the device as a plain label. Returns the tag with THIS device's values. + setDeviceTagValues(machineId: String!, key: String!, values: [String!]): Tag! + + # Detach a tag from a device. The tag key itself is kept (use deleteTag to drop it + # everywhere). False when the device did not carry the tag. + removeDeviceTag(machineId: String!, tagId: ID!): Boolean! } diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/DeviceDataFetcherTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/DeviceDataFetcherTest.java index 6f532a76d0..42a70ec3a5 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/DeviceDataFetcherTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/DeviceDataFetcherTest.java @@ -3,12 +3,15 @@ import com.openframe.api.mapper.GraphQLDeviceMapper; import com.openframe.api.service.device.DeviceFilterService; import com.openframe.api.service.device.DeviceService; +import com.openframe.api.service.device.DeviceTagService; import com.openframe.api.service.FleetVulnerabilityStatusService; import com.openframe.api.service.TagService; import com.netflix.graphql.dgs.DgsDataFetchingEnvironment; import com.openframe.data.document.device.Machine; +import com.openframe.data.document.tag.Tag; import com.openframe.data.document.tool.ToolConnection; import com.openframe.data.document.tool.ToolType; +import graphql.relay.Relay; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,6 +20,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.Instant; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.never; @@ -28,6 +32,7 @@ class DeviceDataFetcherTest { @Mock private DeviceService deviceService; @Mock private DeviceFilterService deviceFilterService; + @Mock private DeviceTagService deviceTagService; @Mock private TagService tagService; @Mock private GraphQLDeviceMapper mapper; @Mock private FleetVulnerabilityStatusService fleetVulnerabilityStatusService; @@ -90,4 +95,63 @@ void toolConnectionVulnerabilitiesUpdatedAt_meshcentral_nullWithoutServiceCall() assertThat(result).isNull(); verify(fleetVulnerabilityStatusService, never()).getLastCompletedVulnerabilityRunAt(); } + + @Test + @DisplayName("assignDeviceTag: delegates to DeviceTagService.assignTag and returns the device-scoped tag") + void assignDeviceTag_delegates() { + Tag assigned = Tag.builder().id("tag-1").key("site").values(List.of("chicago")).build(); + when(deviceTagService.assignTag("m1", "site", List.of("chicago"))).thenReturn(assigned); + + Tag result = fetcher.assignDeviceTag("m1", "site", List.of("chicago")); + + assertThat(result).isSameAs(assigned); + verify(deviceTagService).assignTag("m1", "site", List.of("chicago")); + } + + @Test + @DisplayName("assignDeviceTag: passes null values through for a plain label tag") + void assignDeviceTag_nullValues() { + Tag assigned = Tag.builder().id("tag-1").key("vip").values(List.of()).build(); + when(deviceTagService.assignTag("m1", "vip", null)).thenReturn(assigned); + + Tag result = fetcher.assignDeviceTag("m1", "vip", null); + + assertThat(result).isSameAs(assigned); + } + + @Test + @DisplayName("setDeviceTagValues: delegates to DeviceTagService.setTagValues, not the additive assignTag") + void setDeviceTagValues_delegates() { + Tag updated = Tag.builder().id("tag-1").key("site").values(List.of("chicago")).build(); + when(deviceTagService.setTagValues("m1", "site", List.of("chicago"))).thenReturn(updated); + + Tag result = fetcher.setDeviceTagValues("m1", "site", List.of("chicago")); + + assertThat(result).isSameAs(updated); + verify(deviceTagService).setTagValues("m1", "site", List.of("chicago")); + verify(deviceTagService, never()).assignTag("m1", "site", List.of("chicago")); + } + + @Test + @DisplayName("removeDeviceTag: decodes the Relay global id before delegating") + void removeDeviceTag_decodesGlobalId() { + String globalId = new Relay().toGlobalId("Tag", "tag-1"); + when(deviceTagService.removeTag("m1", "tag-1")).thenReturn(true); + + boolean result = fetcher.removeDeviceTag("m1", globalId); + + assertThat(result).isTrue(); + verify(deviceTagService).removeTag("m1", "tag-1"); + verify(deviceTagService, never()).removeTag("m1", globalId); + } + + @Test + @DisplayName("removeDeviceTag: returns false when the device did not carry the tag") + void removeDeviceTag_notAssigned() { + when(deviceTagService.removeTag("m1", "tag-1")).thenReturn(false); + + boolean result = fetcher.removeDeviceTag("m1", new Relay().toGlobalId("Tag", "tag-1")); + + assertThat(result).isFalse(); + } } diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/DeviceFiltersSelectionSetTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/DeviceFiltersSelectionSetTest.java index a9c309033d..19233f3d7b 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/DeviceFiltersSelectionSetTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/datafetcher/DeviceFiltersSelectionSetTest.java @@ -7,6 +7,7 @@ import com.openframe.api.mapper.GraphQLDeviceMapper; import com.openframe.api.service.device.DeviceFilterService; import com.openframe.api.service.device.DeviceService; +import com.openframe.api.service.device.DeviceTagService; import com.openframe.api.service.FleetVulnerabilityStatusService; import com.openframe.api.service.TagService; import graphql.ExecutionResult; @@ -70,6 +71,7 @@ class DeviceFiltersSelectionSetTest { @Mock private DeviceService deviceService; @Mock private DeviceFilterService deviceFilterService; + @Mock private DeviceTagService deviceTagService; @Mock private TagService tagService; @Mock private GraphQLDeviceMapper mapper; @Mock private FleetVulnerabilityStatusService fleetVulnerabilityStatusService; @@ -80,7 +82,7 @@ class DeviceFiltersSelectionSetTest { @BeforeEach void setUp() { - DeviceDataFetcher dataFetcher = new DeviceDataFetcher(deviceService, deviceFilterService, tagService, fleetVulnerabilityStatusService, mapper); + DeviceDataFetcher dataFetcher = new DeviceDataFetcher(deviceService, deviceFilterService, deviceTagService, tagService, fleetVulnerabilityStatusService, mapper); TypeDefinitionRegistry registry = new SchemaParser().parse(SDL); RuntimeWiring wiring = RuntimeWiring.newRuntimeWiring()