Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions LogicMonitor.Api.Test/Resources/EntityPropertyWriteTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using Newtonsoft.Json;

namespace LogicMonitor.Api.Test.Resources;

/// <summary>
/// Portal-free tests for <see cref="EntityPropertyWrite" /> - the config-as-code shape used to write
/// hidden (masked) property fields such as snmp.community / *.pass / *.key onto resources and groups.
/// No network or credentials required.
/// </summary>
public class EntityPropertyWriteTests
{
[Fact]
public void HiddenFieldWriteConfig_Deserializes()
{
// The exact config shape a caller supplies to write hidden fields.
const string json = """
[
{ "type": "resourceGroup", "id": 1234, "name": "snmp.community", "value": "public" }
]
""";

var writes = JsonConvert.DeserializeObject<List<EntityPropertyWrite>>(json);

writes.Should().NotBeNull();
writes!.Should().HaveCount(1);

var write = writes[0];
write.Type.Should().Be(EntityPropertyWriteTargetType.ResourceGroup);
write.Id.Should().Be(1234);
write.Name.Should().Be("snmp.community");
write.Value.Should().Be("public");

// The write targets the group's own properties collection (one field), so the caller always
// supplies the real value rather than round-tripping a masked ********.
write.PropertiesSubUrl().Should().Be("device/groups/1234/properties");
}

[Fact]
public void ResourceTarget_MapsToDevicePropertiesEndpoint()
{
var write = new EntityPropertyWrite
{
Type = EntityPropertyWriteTargetType.Resource,
Id = 42,
Name = "esx.pass",
Value = "s3cret"
};

write.PropertiesSubUrl().Should().Be("device/devices/42/properties");
}

[Fact]
public void RoundTrips_WithCamelCaseTargetType()
{
var write = new EntityPropertyWrite
{
Type = EntityPropertyWriteTargetType.ResourceGroup,
Id = 1234,
Name = "snmp.community",
Value = "public"
};

var json = JObject.Parse(JsonConvert.SerializeObject(write));

json["type"]!.Value<string>().Should().Be("resourceGroup");
json["id"]!.Value<int>().Should().Be(1234);
json["name"]!.Value<string>().Should().Be("snmp.community");
json["value"]!.Value<string>().Should().Be("public");
}

[Fact]
public void UnknownTarget_Throws()
{
var write = new EntityPropertyWrite { Type = EntityPropertyWriteTargetType.Unknown, Id = 1, Name = "x" };

var act = write.PropertiesSubUrl;

act.Should().Throw<NotSupportedException>();
}
}
206 changes: 206 additions & 0 deletions LogicMonitor.Api.Test/Resources/ResourceRenameTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
namespace LogicMonitor.Api.Test.Resources;

/// <summary>
/// Integration tests (live portal) proving:
/// 1. A <see cref="ResourceGroup" /> can be renamed via a full-object <see cref="LogicMonitorClient.PutAsync{T}" />.
/// 2. An Uptime ping-check <see cref="Resource" /> can be renamed via PutAsync - and that BOTH Name and
/// DisplayName must be set (a DisplayName-only change silently no-ops on uptimepingcheck devices), while
/// the ping target (Host) is preserved.
/// 3. A hidden/secret custom property (snmp.community) can be set/rotated via
/// <see cref="LogicMonitorClient.SetCustomPropertyAsync(EntityPropertyWrite, SetPropertyMode, CancellationToken)" />
/// (one field at a time, supplying the real value) - the admin-gated, config-as-code way to write
/// hidden fields.
///
/// Note on the original masked-secret concern: a full-object PUT round-trip does NOT clobber a
/// Resource/ResourceGroup secret - LogicMonitor treats the ******** mask as "unchanged" on write. This
/// was verified empirically on a live portal (set test.pass, GET->PUT the whole object, then read the
/// real stored value back via a reveal PropertySource that echoes nominated hidden props, with a
/// fresh-run marker to defeat staleness; the value survived). So PutAsync-based rename is secret-safe.
/// </summary>
public class ResourceRenameTests(ITestOutputHelper iTestOutputHelper, Fixture fixture)
: TestWithOutput(iTestOutputHelper, fixture), IClassFixture<Fixture>
{
private const string TargetHost = "8.8.8.8";

Check warning on line 23 in LogicMonitor.Api.Test/Resources/ResourceRenameTests.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

LogicMonitor.Api.Test/Resources/ResourceRenameTests.cs#L23

Make sure using this hardcoded IP address '8.8.8.8' is safe here.

[Fact]
public async Task PutAsync_RenamesResourceGroup()
{
const string originalName = "IntegrationTest-Rename-Group-Before";
const string renamedName = "IntegrationTest-Rename-Group-After";

await DeleteResourceGroupIfExistsAsync(originalName);
await DeleteResourceGroupIfExistsAsync(renamedName);

var group = await LogicMonitorClient.CreateAsync(
new ResourceGroupCreationDto { ParentId = "1", Name = originalName },
CancellationToken);

try
{
// Fetch the full object, change the Name, and PUT it back.
var fetched = await LogicMonitorClient.GetAsync<ResourceGroup>(group.Id, CancellationToken);
fetched.Name = renamedName;
await LogicMonitorClient.PutAsync(fetched, CancellationToken);

var reloaded = await LogicMonitorClient.GetAsync<ResourceGroup>(group.Id, CancellationToken);
reloaded.Name.Should().Be(renamedName);
}
finally
{
await LogicMonitorClient.DeleteAsync(group, true, CancellationToken);
}
}

[Fact]
public async Task PutAsync_RenamesUptimePingCheckResource_SettingNameAndDisplayName()

Check warning on line 55 in LogicMonitor.Api.Test/Resources/ResourceRenameTests.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

LogicMonitor.Api.Test/Resources/ResourceRenameTests.cs#L55

Method ResourceRenameTests::PutAsync_RenamesUptimePingCheckResource_SettingNameAndDisplayName has 64 lines of code (limit is 50)
{
const string originalName = "IntegrationTest-Rename-Ping-Before";
const string renamedName = "IntegrationTest-Rename-Ping-After";

await DeleteResourceIfExistsAsync(originalName);
await DeleteResourceIfExistsAsync(renamedName);

var collectorId = await GetLiveCollectorIdAsync();

PingCheckResource resource;
try
{
resource = await LogicMonitorClient.CreateAsync(
new PingCheckResourceCreationDto
{
Name = originalName,
DisplayName = originalName,
Description = "Integration test rename ping check",
ResourceGroupIds = "1",
PreferredCollectorId = collectorId,
DisableAlerting = true,
IsInternal = true,
HostName = TargetHost,
PollingIntervalMinutes = 5,
PacketCount = 5,
TimeoutMs = 500,
PercentPacketsNotReceivedInTime = 80,
SyntheticsCollectorIds = [collectorId],
TestLocation = new UptimeTestLocation { All = true, CollectorIds = [collectorId], SmgIds = [] },
Alerting = new UptimeAlertSettings
{
OverallAlertLevel = Level.Critical,
IndividualAlertLevel = Level.Warning,
IndividualCheckpointAlertsEnabled = true,
FailedCheckCountBeforeAlerting = 1,
AlertCondition = SiteMonitorAlertCondition.AllLocations
}
},
CancellationToken);
}
catch (LogicMonitorApiException ex) when (IsUptimeUnavailable(ex))
{
Assert.Skip($"Portal does not have LM Uptime enabled: {ex.Message}");
return;
}

try
{
var fetched = await LogicMonitorClient.GetAsync<PingCheckResource>(resource.Id, CancellationToken);
var originalHost = fetched.HostName;

// A rename on a uptimepingcheck device must set BOTH Name and DisplayName; a DisplayName-only
// change silently no-ops on these devices. For these devices Name is a label, not the ping
// target (that is HostName), so changing Name is safe.
fetched.Name = renamedName;
fetched.DisplayName = renamedName;
await LogicMonitorClient.PutAsync(fetched, CancellationToken);

var reloaded = await LogicMonitorClient.GetAsync<PingCheckResource>(resource.Id, CancellationToken);
reloaded.Name.Should().Be(renamedName);
reloaded.DisplayName.Should().Be(renamedName);
reloaded.HostName.Should().Be(originalHost, "the ping target must be preserved across a rename");
reloaded.ResourceType.Should().Be(ResourceType.Ping);
}
finally
{
if (resource.Id > 0)
{
await LogicMonitorClient.DeleteAsync(resource, cancellationToken: CancellationToken);
}
}
}

[Fact]
public async Task SetCustomPropertyAsync_WritesHiddenField_ViaEntityPropertyWrite()
{
const string groupName = "IntegrationTest-HiddenField-Group";
const string secretPropertyName = "snmp.community";

await DeleteResourceGroupIfExistsAsync(groupName);

var group = await LogicMonitorClient.CreateAsync(
new ResourceGroupCreationDto { ParentId = "1", Name = groupName },
CancellationToken);

try
{
// Write the hidden field one property at a time, supplying the real value (never round-tripping
// a masked ********). This is the admin-gated config-as-code shape used to set/rotate hidden
// fields: [ { "type": "resourceGroup", "id": <id>, "name": "snmp.community", "value": "public" } ]
var write = new EntityPropertyWrite
{
Type = EntityPropertyWriteTargetType.ResourceGroup,
Id = group.Id,
Name = secretPropertyName,
Value = "public"
};
await LogicMonitorClient.SetCustomPropertyAsync(write, SetPropertyMode.Create, CancellationToken);

var properties = await LogicMonitorClient.GetResourceGroupPropertiesAsync(group.Id, CancellationToken);
properties.Should().ContainSingle(p => p.Name == secretPropertyName,
"the hidden field must be written (its value is masked as ******** by LogicMonitor on read)");

// The batch entry point applies the same write as a config list.
await LogicMonitorClient.SetCustomPropertiesAsync([write], SetPropertyMode.Update, CancellationToken);

properties = await LogicMonitorClient.GetResourceGroupPropertiesAsync(group.Id, CancellationToken);
properties.Should().ContainSingle(p => p.Name == secretPropertyName);
}
finally
{
await LogicMonitorClient.DeleteAsync(group, true, CancellationToken);
}
}

/// <summary>
/// True when a creation error indicates the portal cannot host LM Uptime checks (feature disabled or the
/// Ping_Check LogicModules are not imported) - in which case the test should be skipped, not failed.
/// </summary>
private static bool IsUptimeUnavailable(LogicMonitorApiException ex)
=> ex.Message.Contains("Uptime feature is not enabled", StringComparison.OrdinalIgnoreCase)
|| ex.Message.Contains("datasources not found", StringComparison.OrdinalIgnoreCase);

private async Task<int> GetLiveCollectorIdAsync()
{
var collectors = await LogicMonitorClient.GetAllAsync<Collector>(CancellationToken);
var collector = collectors.FirstOrDefault(c => c.Id == CollectorId)
?? collectors.FirstOrDefault(c => !c.IsDown)
?? collectors.FirstOrDefault();
collector.Should().NotBeNull("the portal must have at least one Collector for an internal Uptime check");
return collector!.Id;
}

private async Task DeleteResourceGroupIfExistsAsync(string fullPath)
{
var existing = await LogicMonitorClient.GetResourceGroupByFullPathAsync(fullPath, CancellationToken);
if (existing is not null)
{
await LogicMonitorClient.DeleteAsync(existing, true, CancellationToken);
}
}

private async Task DeleteResourceIfExistsAsync(string displayName)
{
var existing = await LogicMonitorClient.GetResourceByDisplayNameAsync(displayName, CancellationToken);
if (existing is not null)
{
await LogicMonitorClient.DeleteAsync(existing, cancellationToken: CancellationToken);
}
}
}
50 changes: 50 additions & 0 deletions LogicMonitor.Api/LogicMonitorClient_Resources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,56 @@ public Task SetResourceGroupCustomPropertyAsync(
"device/groups",
cancellationToken);

/// <summary>
/// Applies a single <see cref="EntityPropertyWrite" />, writing one custom property onto the
/// target <see cref="Resource" /> or <see cref="ResourceGroup" />.
/// </summary>
/// <remarks>
/// This writes only the named property (never a full object), so you always supply the real value
/// rather than round-tripping a masked <c>********</c>. It is the admin-gated way to set or rotate
/// hidden fields such as <c>snmp.community</c>, <c>*.pass</c> and <c>*.key</c>. (For reference: a
/// full-object PUT does not clobber a Resource/ResourceGroup secret either - the server treats the
/// <c>********</c> mask as "unchanged" - but this method is how you change the stored value.)
/// </remarks>
/// <param name="write">The property write directive.</param>
/// <param name="mode">How to set the property (Create, Update, Delete or Automatic).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <exception cref="NotSupportedException">Thrown when the write targets an unsupported entity type.</exception>
public Task SetCustomPropertyAsync(
EntityPropertyWrite write,
SetPropertyMode mode,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(write);
return write.Type switch
{
EntityPropertyWriteTargetType.Resource
=> SetResourceCustomPropertyAsync(write.Id, write.Name, write.Value, mode, cancellationToken),
EntityPropertyWriteTargetType.ResourceGroup
=> SetResourceGroupCustomPropertyAsync(write.Id, write.Name, write.Value, mode, cancellationToken),
_ => throw new NotSupportedException($"Unsupported {nameof(EntityPropertyWriteTargetType)}: {write.Type}")
};
}

/// <summary>
/// Applies a batch of <see cref="EntityPropertyWrite" /> directives in order. This is the
/// config-as-code entry point for setting hidden property fields across resources and groups.
/// </summary>
/// <param name="writes">The property write directives to apply, in order.</param>
/// <param name="mode">How to set each property (Create, Update, Delete or Automatic).</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task SetCustomPropertiesAsync(
IEnumerable<EntityPropertyWrite> writes,
SetPropertyMode mode,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
foreach (var write in writes)
{
await SetCustomPropertyAsync(write, mode, cancellationToken).ConfigureAwait(false);
}
}

/// <summary>
/// Gets Resources
/// </summary>
Expand Down
Loading
Loading