Commit 189cd429 authored by Christos Tranoris's avatar Christos Tranoris
Browse files

fix for #2

parent 4168cb64
Loading
Loading
Loading
Loading
Loading
+344 −0
Original line number Diff line number Diff line
package org.etsi.osl.controllers.capif.invoker;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.util.HashMap;
import java.util.Map;
import org.etsi.osl.tmf.rcm634.model.ResourceSpecificationRef;
import org.etsi.osl.tmf.ri639.model.Characteristic;
import org.etsi.osl.tmf.ri639.model.Resource;
import org.etsi.osl.tmf.ri639.model.ResourceAdministrativeStateType;
import org.etsi.osl.tmf.ri639.model.ResourceHealth;
import org.etsi.osl.tmf.ri639.model.ResourceOperationalStateType;
import org.etsi.osl.tmf.ri639.model.ResourceStatusType;
import org.etsi.osl.tmf.ri639.model.ResourceUpdate;
import org.etsi.osl.tmf.ri639.model.ResourceUsageStateType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;

/**
 * Pins the ITU-T X.731 health this controller reports on every path that writes a resource back to
 * the TMF catalog.
 *
 * <p>OSOM decides a supporting service's lifecycle from {@code Resource.deriveHealth()}, which
 * folds {@code operationalState} and {@code administrativeState} into a verdict.
 * {@code resourceStatus} takes no part in it. A path that reports neither attribute derives
 * {@code PENDING}, which neither promotes nor demotes the service: the order hangs with no
 * exception and nothing in any log, which is exactly the failure this suite exists to prevent
 * recurring. Every assertion below therefore states the verdict the way OSOM reads it, by folding
 * what was written through {@code deriveHealth()}.
 */
class ResourceRepoServiceHealthTest {

  private static final String RESOURCE_ID = "res-1";
  private static final String INVOKER_NAME = "myInvoker";

  private ResourceRepoService repo;
  private CatalogClient catalogClient;
  private InvokerManagement invokerManagement;

  @BeforeEach
  void setUp() {
    repo = new ResourceRepoService();
    catalogClient = mock(CatalogClient.class);
    invokerManagement = mock(InvokerManagement.class);

    repo.aCatalogClient = catalogClient;
    repo.invokerManagement = invokerManagement;
    repo.simpleResourceMapper = mock(SimpleResourceMapper.class);

    ReflectionTestUtils.setField(repo, "compcategory", "CAPIF");
    ReflectionTestUtils.setField(repo, "compversion", "1.0.0");

    when(catalogClient.updateResourceById(anyString(), any(ResourceUpdate.class)))
        .thenReturn(new Resource());
  }

  // ---------------------------------------------------------------- create / update

  @Test
  @DisplayName("an onboarded invoker is reported UP, so the supporting service can reach ACTIVE")
  void onboardedInvokerIsReportedUp() throws Exception {

    when(invokerManagement.onboardInvokerByName(INVOKER_NAME)).thenReturn(anInvoker());

    repo.updateResource(headers(), anUpdateFor(INVOKER_NAME));

    ResourceUpdate written = captureWrite();
    assertThat(written.getOperationalState()).isEqualTo(ResourceOperationalStateType.ENABLE);
    assertThat(written.getAdministrativeState()).isEqualTo(ResourceAdministrativeStateType.UNLOCKED);
    assertThat(written.getUsageState()).isEqualTo(ResourceUsageStateType.ACTIVE);
    assertThat(healthOf(written)).isEqualTo(ResourceHealth.UP);

    assertThat(written.getResourceStatus()).isEqualTo(ResourceStatusType.AVAILABLE);
    assertThat(lastValueOf(written, "status.Health")).isEqualTo("UP");
    assertThat(lastValueOf(written, "status.infoMessage")).isEqualTo("onboarded to CAPIF service");
  }

  @Test
  @DisplayName("an onboarded invoker is remembered, so its bearer token gets refreshed later")
  void onboardedInvokerIsRemembered() throws Exception {

    when(invokerManagement.onboardInvokerByName(INVOKER_NAME)).thenReturn(anInvoker());

    repo.updateResource(headers(), anUpdateFor(INVOKER_NAME));

    assertThat(repo.resourceToInvokerMap).containsKey(RESOURCE_ID);
  }

  @Test
  @DisplayName("an invoker name this controller does not know is reported DOWN, never left unreported")
  void unknownInvokerNameIsReportedDown() throws Exception {

    // onboardInvokerByName returns null for a name that is not in the configured invokers. This
    // branch used to fall through with resourceStatus=UNKNOWN and no health at all, which reads as
    // PENDING: the service would wait for an invoker that is never coming.
    when(invokerManagement.onboardInvokerByName("nosuch")).thenReturn(null);

    repo.updateResource(headers(), anUpdateFor("nosuch"));

    ResourceUpdate written = captureWrite();
    assertThat(healthOf(written)).isEqualTo(ResourceHealth.DOWN);
    assertThat(written.getUsageState()).isEqualTo(ResourceUsageStateType.IDLE);
    assertThat(written.getResourceStatus()).isEqualTo(ResourceStatusType.ALARM);
    assertThat(lastValueOf(written, "status.state")).isEqualTo(CapifInvokerState.MISSING.name());
    assertThat(lastValueOf(written, "status.infoMessage")).contains("nosuch");
    assertThat(lastValueOf(written, "status.Health")).isEqualTo("DOWN");

    assertThat(repo.resourceToInvokerMap).doesNotContainKey(RESOURCE_ID);
  }

  @Test
  @DisplayName("a failed onboarding is reported DOWN with the reason, which demotes rather than hangs")
  void failedOnboardingIsReportedDown() throws Exception {

    when(invokerManagement.onboardInvokerByName(INVOKER_NAME))
        .thenThrow(new IllegalStateException("CAPIF refused the certificate"));

    repo.updateResource(headers(), anUpdateFor(INVOKER_NAME));

    ResourceUpdate written = captureWrite();
    assertThat(healthOf(written)).isEqualTo(ResourceHealth.DOWN);
    // DISABLE while still UNLOCKED: not working, but not torn down either.
    assertThat(written.getAdministrativeState()).isEqualTo(ResourceAdministrativeStateType.UNLOCKED);
    assertThat(written.getResourceStatus()).isEqualTo(ResourceStatusType.ALARM);
    assertThat(lastValueOf(written, "status.state")).isEqualTo(CapifInvokerState.ERROR.name());
    assertThat(lastValueOf(written, "status.infoMessage")).contains("CAPIF refused the certificate");
    assertThat(lastValueOf(written, "status.Health")).isEqualTo("DOWN");
  }

  // ---------------------------------------------------------------- delete

  @Test
  @DisplayName("a deleted resource is reported GONE, which is what tears the service down")
  void deletedResourceIsReportedGone() {

    repo.resourceToInvokerMap.put(RESOURCE_ID, aCapifInvokerResource());

    repo.deleteResource(headers(), new ResourceUpdate());

    ResourceUpdate written = captureWrite();
    assertThat(written.getOperationalState()).isEqualTo(ResourceOperationalStateType.DISABLE);
    // SHUTDOWN is the teardown tombstone; resourceStatus=UNKNOWN on its own moves nothing.
    assertThat(written.getAdministrativeState()).isEqualTo(ResourceAdministrativeStateType.SHUTDOWN);
    assertThat(written.getUsageState()).isEqualTo(ResourceUsageStateType.IDLE);
    assertThat(healthOf(written)).isEqualTo(ResourceHealth.GONE);

    assertThat(written.getResourceStatus()).isEqualTo(ResourceStatusType.UNKNOWN);
    assertThat(lastValueOf(written, "status.Health")).isEqualTo("GONE");
    assertThat(repo.resourceToInvokerMap).doesNotContainKey(RESOURCE_ID);
  }

  // ---------------------------------------------------------------- bearer refresh heartbeat

  @Test
  @DisplayName("a refreshed bearer token is reported UP with the new token")
  void refreshedBearerIsReportedUp() throws Exception {

    repo.resourceToInvokerMap.put(RESOURCE_ID, aCapifInvokerResource());

    CapifInvoker refreshed = anInvoker();
    refreshed.setBearerAccessToken("fresh-token");
    when(invokerManagement.refreshBearer(any(CapifInvoker.class))).thenReturn(refreshed);

    repo.processBearerRefreshTokens();

    ResourceUpdate written = captureWrite();
    assertThat(healthOf(written)).isEqualTo(ResourceHealth.UP);
    assertThat(written.getUsageState()).isEqualTo(ResourceUsageStateType.ACTIVE);
    // The sweeper says nothing about pool disposition: the update leaves resourceStatus unset, and
    // the TMF inventory applies only the fields an update carries, so a reservation survives it.
    assertThat(written.getResourceStatus()).isNull();
    assertThat(lastValueOf(written, "bearerAccessToken")).isEqualTo("fresh-token");
    assertThat(lastValueOf(written, "status.infoMessage")).startsWith("Bearer token refreshed");
  }

  @Test
  @DisplayName("a bearer refresh that failed is reported DOWN, not as a refresh that succeeded")
  void failedBearerRefreshIsReportedDown() throws Exception {

    // refreshBearer used to swallow its exception and hand the stale invoker back, so this loop
    // wrote "Bearer token refreshed" onto a resource whose token was quietly expiring.
    repo.resourceToInvokerMap.put(RESOURCE_ID, aCapifInvokerResource());
    when(invokerManagement.refreshBearer(any(CapifInvoker.class)))
        .thenThrow(new IllegalStateException("CAPIF unreachable"));

    repo.processBearerRefreshTokens();

    ResourceUpdate written = captureWrite();
    assertThat(healthOf(written)).isEqualTo(ResourceHealth.DOWN);
    // The bad news travels as health. Writing ALARM from here would clear a reservation the
    // sweeper knows nothing about, and DOWN already demotes the supporting service.
    assertThat(written.getResourceStatus()).isNull();
    assertThat(lastValueOf(written, "status.state")).isEqualTo(CapifInvokerState.ERROR.name());
    assertThat(lastValueOf(written, "status.infoMessage"))
        .contains("Bearer token refresh failed")
        .contains("CAPIF unreachable");
    assertThat(lastValueOf(written, "status.Health")).isEqualTo("DOWN");
  }

  @Test
  @DisplayName("one unwritable resource does not starve the rest of the refresh loop")
  void oneUnwritableResourceDoesNotStarveTheRest() throws Exception {

    repo.resourceToInvokerMap.put("res-bad", aCapifInvokerResource());
    repo.resourceToInvokerMap.put("res-good", aCapifInvokerResource());

    when(invokerManagement.refreshBearer(any(CapifInvoker.class))).thenReturn(anInvoker());
    when(catalogClient.updateResourceById(eq("res-bad"), any(ResourceUpdate.class)))
        .thenThrow(new RuntimeException("catalog unreachable"));

    repo.processBearerRefreshTokens();

    verify(catalogClient).updateResourceById(eq("res-good"), any(ResourceUpdate.class));
  }

  // ------------------------------------------------- allocation someone else made

  @Test
  @DisplayName("onboarding does not release a reservation this controller did not make")
  void onboardingLeavesAReservationAlone() throws Exception {

    // The ResourceUpdate is built from the live catalog entry, so an orchestrator's RESERVED
    // arrives inside it. Reporting AVAILABLE here would put the resource back in the free pool
    // while the order that reserved it is still using it.
    when(invokerManagement.onboardInvokerByName(INVOKER_NAME)).thenReturn(anInvoker());
    ResourceUpdate reserved = anUpdateFor(INVOKER_NAME);
    reserved.setResourceStatus(ResourceStatusType.RESERVED);

    repo.updateResource(headers(), reserved);

    ResourceUpdate written = captureWrite();
    assertThat(written.getResourceStatus()).isEqualTo(ResourceStatusType.RESERVED);
    // ...and the health that actually promotes the service is unaffected.
    assertThat(healthOf(written)).isEqualTo(ResourceHealth.UP);
  }

  @Test
  @DisplayName("a failed onboarding does not release a reservation either")
  void failedOnboardingLeavesAReservationAlone() throws Exception {

    when(invokerManagement.onboardInvokerByName(INVOKER_NAME))
        .thenThrow(new IllegalStateException("CAPIF refused the certificate"));
    ResourceUpdate reserved = anUpdateFor(INVOKER_NAME);
    reserved.setResourceStatus(ResourceStatusType.RESERVED);

    repo.updateResource(headers(), reserved);

    ResourceUpdate written = captureWrite();
    assertThat(written.getResourceStatus()).isEqualTo(ResourceStatusType.RESERVED);
    assertThat(healthOf(written)).isEqualTo(ResourceHealth.DOWN);
  }

  @Test
  @DisplayName("teardown does end the allocation, because the resource it was made against is going")
  void deleteClearsTheAllocation() {

    ResourceUpdate reserved = new ResourceUpdate();
    reserved.setResourceStatus(ResourceStatusType.RESERVED);

    repo.deleteResource(headers(), reserved);

    assertThat(captureWrite().getResourceStatus()).isEqualTo(ResourceStatusType.UNKNOWN);
  }

  @Test
  @DisplayName("a stale ALARM is superseded by a successful onboarding: a condition is only an observation")
  void onboardingSupersedesAStaleAlarm() throws Exception {

    when(invokerManagement.onboardInvokerByName(INVOKER_NAME)).thenReturn(anInvoker());
    ResourceUpdate alarmed = anUpdateFor(INVOKER_NAME);
    alarmed.setResourceStatus(ResourceStatusType.ALARM);

    repo.updateResource(headers(), alarmed);

    assertThat(captureWrite().getResourceStatus()).isEqualTo(ResourceStatusType.AVAILABLE);
  }

  // ---------------------------------------------------------------- helpers

  private Map<String, Object> headers() {
    Map<String, Object> headers = new HashMap<>();
    headers.put("org.etsi.osl.resourceId", RESOURCE_ID);
    return headers;
  }

  private ResourceUpdate anUpdateFor(String specName) {
    ResourceUpdate update = new ResourceUpdate();
    ResourceSpecificationRef ref = new ResourceSpecificationRef();
    ref.setName(specName);
    update.setResourceSpecification(ref);
    return update;
  }

  private CapifInvoker anInvoker() {
    CapifInvoker invoker = new CapifInvoker();
    invoker.setName(INVOKER_NAME);
    invoker.setDescription("a CAPIF invoker");
    invoker.setBearerAccessToken("a-token");
    invoker.setDiscoveredServiceAPIs("[]");
    invoker.setCapifInvokerState(CapifInvokerState.ONBOARDED);
    return invoker;
  }

  private CapifInvokerResource aCapifInvokerResource() {
    return new CapifInvokerResource(INVOKER_NAME, "1.0.0", "a CAPIF invoker", "CAPIF", anInvoker());
  }

  private ResourceUpdate captureWrite() {
    ArgumentCaptor<ResourceUpdate> captor = ArgumentCaptor.forClass(ResourceUpdate.class);
    verify(catalogClient).updateResourceById(anyString(), captor.capture());
    return captor.getValue();
  }

  /** Folds what was written the way OSOM folds it, rather than restating the attribute pair. */
  private ResourceHealth healthOf(ResourceUpdate update) {
    Resource asRead = new Resource();
    asRead.setOperationalState(update.getOperationalState());
    asRead.setAdministrativeState(update.getAdministrativeState());
    return asRead.deriveHealth();
  }

  /**
   * {@code addResourceCharacteristicItemShort} appends rather than replaces, so a name written
   * twice appears twice. A consumer applying them in order ends up with the last one.
   */
  private String lastValueOf(ResourceUpdate update, String name) {
    String value = null;
    for (Characteristic c : update.getResourceCharacteristic()) {
      if (name.equals(c.getName()) && c.getValue() != null) {
        value = String.valueOf(c.getValue().getValue());
      }
    }
    return value;
  }
}