|
|
|
|
@@ -1,417 +1,449 @@
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Net;
|
|
|
|
|
using System.Net.Http;
|
|
|
|
|
using System.Net.Http.Headers;
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
|
using StellaOps.Notify.Engine;
|
|
|
|
|
using StellaOps.Notify.Models;
|
|
|
|
|
|
|
|
|
|
namespace StellaOps.Notify.WebService.Tests;
|
|
|
|
|
|
|
|
|
|
public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
|
|
|
|
|
{
|
|
|
|
|
private const string SigningKey = "super-secret-test-key-1234567890";
|
|
|
|
|
private const string Issuer = "test-issuer";
|
|
|
|
|
private const string Audience = "notify";
|
|
|
|
|
|
|
|
|
|
private readonly WebApplicationFactory<Program> _factory;
|
|
|
|
|
private readonly string _adminToken;
|
|
|
|
|
private readonly string _readToken;
|
|
|
|
|
|
|
|
|
|
public CrudEndpointsTests(WebApplicationFactory<Program> factory)
|
|
|
|
|
{
|
|
|
|
|
_factory = factory.WithWebHostBuilder(builder =>
|
|
|
|
|
{
|
|
|
|
|
builder.UseSetting("notify:storage:driver", "memory");
|
|
|
|
|
builder.UseSetting("notify:authority:enabled", "false");
|
|
|
|
|
builder.UseSetting("notify:authority:developmentSigningKey", SigningKey);
|
|
|
|
|
builder.UseSetting("notify:authority:issuer", Issuer);
|
|
|
|
|
builder.UseSetting("notify:authority:audiences:0", Audience);
|
|
|
|
|
builder.UseSetting("notify:authority:adminScope", "notify.admin");
|
|
|
|
|
builder.UseSetting("notify:authority:readScope", "notify.read");
|
|
|
|
|
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:tokenLimit", "10");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:tokensPerPeriod", "10");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:queueLimit", "5");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:deliveryHistory:tokenLimit", "30");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:deliveryHistory:tokensPerPeriod", "30");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:deliveryHistory:queueLimit", "10");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_adminToken = CreateToken("notify.admin");
|
|
|
|
|
_readToken = CreateToken("notify.read");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task InitializeAsync() => Task.CompletedTask;
|
|
|
|
|
|
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task RuleCrudLifecycle()
|
|
|
|
|
{
|
|
|
|
|
var client = _factory.CreateClient();
|
|
|
|
|
var payload = LoadSample("notify-rule@1.sample.json");
|
|
|
|
|
payload["ruleId"] = "rule-web";
|
|
|
|
|
payload["tenantId"] = "tenant-web";
|
|
|
|
|
payload["actions"]!.AsArray()[0]! ["actionId"] = "action-web";
|
|
|
|
|
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/rules", payload);
|
|
|
|
|
|
|
|
|
|
var list = await GetJsonArrayAsync(client, "/api/v1/notify/rules", useAdminToken: false);
|
|
|
|
|
Assert.Equal("rule-web", list?[0]? ["ruleId"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
var single = await GetJsonObjectAsync(client, "/api/v1/notify/rules/rule-web", useAdminToken: false);
|
|
|
|
|
Assert.Equal("tenant-web", single? ["tenantId"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
await DeleteAsync(client, "/api/v1/notify/rules/rule-web");
|
|
|
|
|
var afterDelete = await SendAsync(client, HttpMethod.Get, "/api/v1/notify/rules/rule-web", useAdminToken: false);
|
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ChannelTemplateDeliveryAndAuditFlows()
|
|
|
|
|
{
|
|
|
|
|
var client = _factory.CreateClient();
|
|
|
|
|
|
|
|
|
|
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
|
|
|
|
channelPayload["channelId"] = "channel-web";
|
|
|
|
|
channelPayload["tenantId"] = "tenant-web";
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
|
|
|
|
|
|
|
|
|
var templatePayload = LoadSample("notify-template@1.sample.json");
|
|
|
|
|
templatePayload["templateId"] = "template-web";
|
|
|
|
|
templatePayload["tenantId"] = "tenant-web";
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/templates", templatePayload);
|
|
|
|
|
|
|
|
|
|
var delivery = NotifyDelivery.Create(
|
|
|
|
|
deliveryId: "delivery-web",
|
|
|
|
|
tenantId: "tenant-web",
|
|
|
|
|
ruleId: "rule-web",
|
|
|
|
|
actionId: "channel-web",
|
|
|
|
|
eventId: Guid.NewGuid(),
|
|
|
|
|
kind: NotifyEventKinds.ScannerReportReady,
|
|
|
|
|
status: NotifyDeliveryStatus.Sent,
|
|
|
|
|
createdAt: DateTimeOffset.UtcNow,
|
|
|
|
|
sentAt: DateTimeOffset.UtcNow);
|
|
|
|
|
|
|
|
|
|
var deliveryNode = JsonNode.Parse(NotifyCanonicalJsonSerializer.Serialize(delivery))!;
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/deliveries", deliveryNode);
|
|
|
|
|
|
|
|
|
|
var deliveriesEnvelope = await GetJsonObjectAsync(client, "/api/v1/notify/deliveries?limit=10", useAdminToken: false);
|
|
|
|
|
Assert.NotNull(deliveriesEnvelope);
|
|
|
|
|
Assert.Equal(1, deliveriesEnvelope? ["count"]?.GetValue<int>());
|
|
|
|
|
Assert.Null(deliveriesEnvelope? ["continuationToken"]?.GetValue<string>());
|
|
|
|
|
var deliveries = deliveriesEnvelope? ["items"] as JsonArray;
|
|
|
|
|
Assert.NotNull(deliveries);
|
|
|
|
|
Assert.NotEmpty(deliveries!.OfType<JsonNode>());
|
|
|
|
|
|
|
|
|
|
var digestNode = new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["tenantId"] = "tenant-web",
|
|
|
|
|
["actionKey"] = "channel-web",
|
|
|
|
|
["window"] = "hourly",
|
|
|
|
|
["openedAt"] = DateTimeOffset.UtcNow.ToString("O"),
|
|
|
|
|
["status"] = "open",
|
|
|
|
|
["items"] = new JsonArray()
|
|
|
|
|
};
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/digests", digestNode);
|
|
|
|
|
|
|
|
|
|
var digest = await GetJsonObjectAsync(client, "/api/v1/notify/digests/channel-web", useAdminToken: false);
|
|
|
|
|
Assert.Equal("channel-web", digest? ["actionKey"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
var auditPayload = JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"action": "create-rule",
|
|
|
|
|
"entityType": "rule",
|
|
|
|
|
"entityId": "rule-web",
|
|
|
|
|
"payload": {"ruleId": "rule-web"}
|
|
|
|
|
}
|
|
|
|
|
""")!;
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/audit", auditPayload);
|
|
|
|
|
|
|
|
|
|
var audits = await GetJsonArrayAsync(client, "/api/v1/notify/audit", useAdminToken: false);
|
|
|
|
|
Assert.NotNull(audits);
|
|
|
|
|
Assert.Contains(audits!.OfType<JsonObject>(), entry => entry?["action"]?.GetValue<string>() == "create-rule");
|
|
|
|
|
|
|
|
|
|
await DeleteAsync(client, "/api/v1/notify/digests/channel-web");
|
|
|
|
|
var digestAfterDelete = await SendAsync(client, HttpMethod.Get, "/api/v1/notify/digests/channel-web", useAdminToken: false);
|
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, digestAfterDelete.StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task LockEndpointsAllowAcquireAndRelease()
|
|
|
|
|
{
|
|
|
|
|
var client = _factory.CreateClient();
|
|
|
|
|
var acquirePayload = JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"resource": "workers",
|
|
|
|
|
"owner": "worker-1",
|
|
|
|
|
"ttlSeconds": 30
|
|
|
|
|
}
|
|
|
|
|
""")!;
|
|
|
|
|
|
|
|
|
|
var acquireResponse = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload);
|
|
|
|
|
var acquireContent = JsonNode.Parse(await acquireResponse.Content.ReadAsStringAsync());
|
|
|
|
|
Assert.True(acquireContent? ["acquired"]?.GetValue<bool>());
|
|
|
|
|
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/locks/release", JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"resource": "workers",
|
|
|
|
|
"owner": "worker-1"
|
|
|
|
|
}
|
|
|
|
|
""")!);
|
|
|
|
|
|
|
|
|
|
var secondAcquire = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload);
|
|
|
|
|
var secondContent = JsonNode.Parse(await secondAcquire.Content.ReadAsStringAsync());
|
|
|
|
|
Assert.True(secondContent? ["acquired"]?.GetValue<bool>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ChannelTestSendReturnsPreview()
|
|
|
|
|
{
|
|
|
|
|
var client = _factory.CreateClient();
|
|
|
|
|
|
|
|
|
|
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
|
|
|
|
channelPayload["channelId"] = "channel-test";
|
|
|
|
|
channelPayload["tenantId"] = "tenant-web";
|
|
|
|
|
channelPayload["config"]! ["target"] = "#ops-alerts";
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
|
|
|
|
|
|
|
|
|
var payload = JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"target": "#ops-alerts",
|
|
|
|
|
"title": "Smoke test",
|
|
|
|
|
"body": "Sample body"
|
|
|
|
|
}
|
|
|
|
|
""")!;
|
|
|
|
|
|
|
|
|
|
var response = await PostAsync(client, "/api/v1/notify/channels/channel-test/test", payload);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
|
|
|
|
|
|
|
|
|
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
|
|
|
|
|
Assert.Equal("tenant-web", json["tenantId"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("channel-test", json["channelId"]?.GetValue<string>());
|
|
|
|
|
Assert.NotNull(json["queuedAt"]);
|
|
|
|
|
Assert.NotNull(json["traceId"]);
|
|
|
|
|
|
|
|
|
|
var preview = json["preview"]?.AsObject();
|
|
|
|
|
Assert.NotNull(preview);
|
|
|
|
|
Assert.Equal("#ops-alerts", preview? ["target"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("Smoke test", preview? ["title"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("Sample body", preview? ["body"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("Sample body"))).ToLowerInvariant();
|
|
|
|
|
Assert.Equal(expectedHash, preview? ["bodyHash"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
var metadata = json["metadata"] as JsonObject;
|
|
|
|
|
Assert.NotNull(metadata);
|
|
|
|
|
Assert.Equal("#ops-alerts", metadata?["target"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("slack", metadata?["channelType"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("fallback", metadata?["previewProvider"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal(json["traceId"]?.GetValue<string>(), metadata?["traceId"]?.GetValue<string>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ChannelTestSendHonoursRateLimit()
|
|
|
|
|
{
|
|
|
|
|
using var limitedFactory = _factory.WithWebHostBuilder(builder =>
|
|
|
|
|
{
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:tokenLimit", "1");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:tokensPerPeriod", "1");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:queueLimit", "0");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var client = limitedFactory.CreateClient();
|
|
|
|
|
|
|
|
|
|
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
|
|
|
|
channelPayload["channelId"] = "channel-rate-limit";
|
|
|
|
|
channelPayload["tenantId"] = "tenant-web";
|
|
|
|
|
channelPayload["config"]! ["target"] = "#ops-alerts";
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
|
|
|
|
|
|
|
|
|
var payload = JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"body": "First"
|
|
|
|
|
}
|
|
|
|
|
""")!;
|
|
|
|
|
|
|
|
|
|
var first = await PostAsync(client, "/api/v1/notify/channels/channel-rate-limit/test", payload);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);
|
|
|
|
|
|
|
|
|
|
var secondRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/channels/channel-rate-limit/test")
|
|
|
|
|
{
|
|
|
|
|
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var second = await SendAsync(client, secondRequest);
|
|
|
|
|
Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode);
|
|
|
|
|
Assert.NotNull(second.Headers.RetryAfter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ChannelTestSendUsesRegisteredProvider()
|
|
|
|
|
{
|
|
|
|
|
var providerName = typeof(FakeSlackTestProvider).FullName!;
|
|
|
|
|
|
|
|
|
|
using var providerFactory = _factory.WithWebHostBuilder(builder =>
|
|
|
|
|
{
|
|
|
|
|
builder.ConfigureServices(services =>
|
|
|
|
|
{
|
|
|
|
|
services.AddSingleton<INotifyChannelTestProvider, FakeSlackTestProvider>();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var client = providerFactory.CreateClient();
|
|
|
|
|
|
|
|
|
|
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
|
|
|
|
channelPayload["channelId"] = "channel-provider";
|
|
|
|
|
channelPayload["tenantId"] = "tenant-web";
|
|
|
|
|
channelPayload["config"]! ["target"] = "#ops-alerts";
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
|
|
|
|
|
|
|
|
|
var payload = JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"target": "#ops-alerts",
|
|
|
|
|
"title": "Provider Title",
|
|
|
|
|
"summary": "Provider Summary"
|
|
|
|
|
}
|
|
|
|
|
""")!;
|
|
|
|
|
|
|
|
|
|
var response = await PostAsync(client, "/api/v1/notify/channels/channel-provider/test", payload);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
|
|
|
|
|
|
|
|
|
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
|
|
|
|
|
var preview = json["preview"]?.AsObject();
|
|
|
|
|
Assert.NotNull(preview);
|
|
|
|
|
Assert.Equal("#ops-alerts", preview?["target"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("Provider Title", preview?["title"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("{\"provider\":\"fake\"}", preview?["body"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
var metadata = json["metadata"]?.AsObject();
|
|
|
|
|
Assert.NotNull(metadata);
|
|
|
|
|
Assert.Equal(providerName, metadata?["previewProvider"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("fake-provider", metadata?["provider.name"]?.GetValue<string>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class FakeSlackTestProvider : INotifyChannelTestProvider
|
|
|
|
|
{
|
|
|
|
|
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
|
|
|
|
|
|
|
|
|
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
var body = "{\"provider\":\"fake\"}";
|
|
|
|
|
var preview = NotifyDeliveryRendered.Create(
|
|
|
|
|
NotifyChannelType.Slack,
|
|
|
|
|
NotifyDeliveryFormat.Slack,
|
|
|
|
|
context.Target,
|
|
|
|
|
context.Request.Title ?? "Provider Title",
|
|
|
|
|
body,
|
|
|
|
|
context.Request.Summary ?? "Provider Summary",
|
|
|
|
|
context.Request.TextBody,
|
|
|
|
|
context.Request.Locale,
|
|
|
|
|
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
|
|
|
|
context.Request.Attachments);
|
|
|
|
|
|
|
|
|
|
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
|
|
|
{
|
|
|
|
|
["provider.name"] = "fake-provider"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static JsonNode LoadSample(string fileName)
|
|
|
|
|
{
|
|
|
|
|
var path = Path.Combine(AppContext.BaseDirectory, fileName);
|
|
|
|
|
if (!File.Exists(path))
|
|
|
|
|
{
|
|
|
|
|
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return JsonNode.Parse(File.ReadAllText(path)) ?? throw new InvalidOperationException("Sample JSON null.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<JsonArray?> GetJsonArrayAsync(HttpClient client, string path, bool useAdminToken)
|
|
|
|
|
{
|
|
|
|
|
var response = await SendAsync(client, HttpMethod.Get, path, useAdminToken);
|
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
|
|
|
return JsonNode.Parse(content) as JsonArray;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<JsonObject?> GetJsonObjectAsync(HttpClient client, string path, bool useAdminToken)
|
|
|
|
|
{
|
|
|
|
|
var response = await SendAsync(client, HttpMethod.Get, path, useAdminToken);
|
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
|
|
|
return JsonNode.Parse(content) as JsonObject;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<HttpResponseMessage> PostAsync(HttpClient client, string path, JsonNode payload, bool useAdminToken = true)
|
|
|
|
|
{
|
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Post, path)
|
|
|
|
|
{
|
|
|
|
|
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var response = await SendAsync(client, request, useAdminToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
|
throw new InvalidOperationException($"Request to {path} failed with {(int)response.StatusCode} {response.StatusCode}: {body}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task<HttpResponseMessage> PostAsync(HttpClient client, string path, JsonNode payload)
|
|
|
|
|
=> PostAsync(client, path, payload, useAdminToken: true);
|
|
|
|
|
|
|
|
|
|
private async Task DeleteAsync(HttpClient client, string path)
|
|
|
|
|
{
|
|
|
|
|
var response = await SendAsync(client, HttpMethod.Delete, path);
|
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, bool useAdminToken = true)
|
|
|
|
|
=> SendAsync(client, new HttpRequestMessage(method, path), useAdminToken);
|
|
|
|
|
|
|
|
|
|
private Task<HttpResponseMessage> SendAsync(HttpClient client, HttpRequestMessage request, bool useAdminToken = true)
|
|
|
|
|
{
|
|
|
|
|
request.Headers.Add("X-StellaOps-Tenant", "tenant-web");
|
|
|
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", useAdminToken ? _adminToken : _readToken);
|
|
|
|
|
return client.SendAsync(request);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string CreateToken(string scope)
|
|
|
|
|
{
|
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
|
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey));
|
|
|
|
|
var descriptor = new SecurityTokenDescriptor
|
|
|
|
|
{
|
|
|
|
|
Issuer = Issuer,
|
|
|
|
|
Audience = Audience,
|
|
|
|
|
Expires = DateTime.UtcNow.AddMinutes(10),
|
|
|
|
|
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
|
|
|
|
|
Subject = new System.Security.Claims.ClaimsIdentity(new[]
|
|
|
|
|
{
|
|
|
|
|
new System.Security.Claims.Claim("scope", scope),
|
|
|
|
|
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "integration-test")
|
|
|
|
|
})
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var token = handler.CreateToken(descriptor);
|
|
|
|
|
return handler.WriteToken(token);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Net;
|
|
|
|
|
using System.Net.Http;
|
|
|
|
|
using System.Net.Http.Headers;
|
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
|
using StellaOps.Notify.Engine;
|
|
|
|
|
using StellaOps.Notify.Models;
|
|
|
|
|
|
|
|
|
|
namespace StellaOps.Notify.WebService.Tests;
|
|
|
|
|
|
|
|
|
|
public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
|
|
|
|
|
{
|
|
|
|
|
private const string SigningKey = "super-secret-test-key-1234567890";
|
|
|
|
|
private const string Issuer = "test-issuer";
|
|
|
|
|
private const string Audience = "notify";
|
|
|
|
|
|
|
|
|
|
private readonly WebApplicationFactory<Program> _factory;
|
|
|
|
|
private readonly string _operatorToken;
|
|
|
|
|
private readonly string _viewerToken;
|
|
|
|
|
|
|
|
|
|
public CrudEndpointsTests(WebApplicationFactory<Program> factory)
|
|
|
|
|
{
|
|
|
|
|
_factory = factory.WithWebHostBuilder(builder =>
|
|
|
|
|
{
|
|
|
|
|
builder.UseSetting("notify:storage:driver", "memory");
|
|
|
|
|
builder.UseSetting("notify:authority:enabled", "false");
|
|
|
|
|
builder.UseSetting("notify:authority:developmentSigningKey", SigningKey);
|
|
|
|
|
builder.UseSetting("notify:authority:issuer", Issuer);
|
|
|
|
|
builder.UseSetting("notify:authority:audiences:0", Audience);
|
|
|
|
|
builder.UseSetting("notify:authority:allowAnonymousFallback", "true");
|
|
|
|
|
builder.UseSetting("notify:authority:adminScope", "notify.admin");
|
|
|
|
|
builder.UseSetting("notify:authority:operatorScope", "notify.operator");
|
|
|
|
|
builder.UseSetting("notify:authority:viewerScope", "notify.viewer");
|
|
|
|
|
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:tokenLimit", "10");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:tokensPerPeriod", "10");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:queueLimit", "5");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:deliveryHistory:tokenLimit", "30");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:deliveryHistory:tokensPerPeriod", "30");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:deliveryHistory:queueLimit", "10");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_operatorToken = CreateToken("notify.viewer", "notify.operator", "notify.admin");
|
|
|
|
|
_viewerToken = CreateToken("notify.viewer");
|
|
|
|
|
|
|
|
|
|
ValidateToken(_operatorToken);
|
|
|
|
|
ValidateToken(_viewerToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void ValidateToken(string token)
|
|
|
|
|
{
|
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
|
|
|
var parameters = new TokenValidationParameters
|
|
|
|
|
{
|
|
|
|
|
ValidateIssuer = true,
|
|
|
|
|
ValidIssuer = Issuer,
|
|
|
|
|
ValidateAudience = true,
|
|
|
|
|
ValidAudience = Audience,
|
|
|
|
|
ValidateIssuerSigningKey = true,
|
|
|
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)),
|
|
|
|
|
ValidateLifetime = true,
|
|
|
|
|
ClockSkew = TimeSpan.FromSeconds(30),
|
|
|
|
|
NameClaimType = System.Security.Claims.ClaimTypes.Name
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handler.ValidateToken(token, parameters, out _);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task InitializeAsync() => Task.CompletedTask;
|
|
|
|
|
|
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task RuleCrudLifecycle()
|
|
|
|
|
{
|
|
|
|
|
var client = _factory.CreateClient();
|
|
|
|
|
var payload = LoadSample("notify-rule@1.sample.json");
|
|
|
|
|
payload["ruleId"] = "rule-web";
|
|
|
|
|
payload["tenantId"] = "tenant-web";
|
|
|
|
|
payload["actions"]!.AsArray()[0]! ["actionId"] = "action-web";
|
|
|
|
|
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/rules", payload);
|
|
|
|
|
|
|
|
|
|
var list = await GetJsonArrayAsync(client, "/api/v1/notify/rules", useOperatorToken: false);
|
|
|
|
|
Assert.Equal("rule-web", list?[0]? ["ruleId"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
var single = await GetJsonObjectAsync(client, "/api/v1/notify/rules/rule-web", useOperatorToken: false);
|
|
|
|
|
Assert.Equal("tenant-web", single? ["tenantId"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
await DeleteAsync(client, "/api/v1/notify/rules/rule-web");
|
|
|
|
|
var afterDelete = await SendAsync(client, HttpMethod.Get, "/api/v1/notify/rules/rule-web", useOperatorToken: false);
|
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ChannelTemplateDeliveryAndAuditFlows()
|
|
|
|
|
{
|
|
|
|
|
var client = _factory.CreateClient();
|
|
|
|
|
|
|
|
|
|
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
|
|
|
|
channelPayload["channelId"] = "channel-web";
|
|
|
|
|
channelPayload["tenantId"] = "tenant-web";
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
|
|
|
|
|
|
|
|
|
var templatePayload = LoadSample("notify-template@1.sample.json");
|
|
|
|
|
templatePayload["templateId"] = "template-web";
|
|
|
|
|
templatePayload["tenantId"] = "tenant-web";
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/templates", templatePayload);
|
|
|
|
|
|
|
|
|
|
var delivery = NotifyDelivery.Create(
|
|
|
|
|
deliveryId: "delivery-web",
|
|
|
|
|
tenantId: "tenant-web",
|
|
|
|
|
ruleId: "rule-web",
|
|
|
|
|
actionId: "channel-web",
|
|
|
|
|
eventId: Guid.NewGuid(),
|
|
|
|
|
kind: NotifyEventKinds.ScannerReportReady,
|
|
|
|
|
status: NotifyDeliveryStatus.Sent,
|
|
|
|
|
createdAt: DateTimeOffset.UtcNow,
|
|
|
|
|
sentAt: DateTimeOffset.UtcNow);
|
|
|
|
|
|
|
|
|
|
var deliveryNode = JsonNode.Parse(NotifyCanonicalJsonSerializer.Serialize(delivery))!;
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/deliveries", deliveryNode);
|
|
|
|
|
|
|
|
|
|
var deliveriesEnvelope = await GetJsonObjectAsync(client, "/api/v1/notify/deliveries?limit=10", useOperatorToken: false);
|
|
|
|
|
Assert.NotNull(deliveriesEnvelope);
|
|
|
|
|
Assert.Equal(1, deliveriesEnvelope? ["count"]?.GetValue<int>());
|
|
|
|
|
Assert.Null(deliveriesEnvelope? ["continuationToken"]?.GetValue<string>());
|
|
|
|
|
var deliveries = deliveriesEnvelope? ["items"] as JsonArray;
|
|
|
|
|
Assert.NotNull(deliveries);
|
|
|
|
|
Assert.NotEmpty(deliveries!.OfType<JsonNode>());
|
|
|
|
|
|
|
|
|
|
var digestNode = new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["tenantId"] = "tenant-web",
|
|
|
|
|
["actionKey"] = "channel-web",
|
|
|
|
|
["window"] = "hourly",
|
|
|
|
|
["openedAt"] = DateTimeOffset.UtcNow.ToString("O"),
|
|
|
|
|
["status"] = "open",
|
|
|
|
|
["items"] = new JsonArray()
|
|
|
|
|
};
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/digests", digestNode);
|
|
|
|
|
|
|
|
|
|
var digest = await GetJsonObjectAsync(client, "/api/v1/notify/digests/channel-web", useOperatorToken: false);
|
|
|
|
|
Assert.Equal("channel-web", digest? ["actionKey"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
var auditPayload = JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"action": "create-rule",
|
|
|
|
|
"entityType": "rule",
|
|
|
|
|
"entityId": "rule-web",
|
|
|
|
|
"payload": {"ruleId": "rule-web"}
|
|
|
|
|
}
|
|
|
|
|
""")!;
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/audit", auditPayload);
|
|
|
|
|
|
|
|
|
|
var audits = await GetJsonArrayAsync(client, "/api/v1/notify/audit", useOperatorToken: false);
|
|
|
|
|
Assert.NotNull(audits);
|
|
|
|
|
Assert.Contains(audits!.OfType<JsonObject>(), entry => entry?["action"]?.GetValue<string>() == "create-rule");
|
|
|
|
|
|
|
|
|
|
await DeleteAsync(client, "/api/v1/notify/digests/channel-web");
|
|
|
|
|
var digestAfterDelete = await SendAsync(client, HttpMethod.Get, "/api/v1/notify/digests/channel-web", useOperatorToken: false);
|
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, digestAfterDelete.StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task LockEndpointsAllowAcquireAndRelease()
|
|
|
|
|
{
|
|
|
|
|
var client = _factory.CreateClient();
|
|
|
|
|
var acquirePayload = JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"resource": "workers",
|
|
|
|
|
"owner": "worker-1",
|
|
|
|
|
"ttlSeconds": 30
|
|
|
|
|
}
|
|
|
|
|
""")!;
|
|
|
|
|
|
|
|
|
|
var acquireResponse = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload);
|
|
|
|
|
var acquireContent = JsonNode.Parse(await acquireResponse.Content.ReadAsStringAsync());
|
|
|
|
|
Assert.True(acquireContent? ["acquired"]?.GetValue<bool>());
|
|
|
|
|
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/locks/release", JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"resource": "workers",
|
|
|
|
|
"owner": "worker-1"
|
|
|
|
|
}
|
|
|
|
|
""")!);
|
|
|
|
|
|
|
|
|
|
var secondAcquire = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload);
|
|
|
|
|
var secondContent = JsonNode.Parse(await secondAcquire.Content.ReadAsStringAsync());
|
|
|
|
|
Assert.True(secondContent? ["acquired"]?.GetValue<bool>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ChannelTestSendReturnsPreview()
|
|
|
|
|
{
|
|
|
|
|
var client = _factory.CreateClient();
|
|
|
|
|
|
|
|
|
|
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
|
|
|
|
channelPayload["channelId"] = "channel-test";
|
|
|
|
|
channelPayload["tenantId"] = "tenant-web";
|
|
|
|
|
channelPayload["config"]! ["target"] = "#ops-alerts";
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
|
|
|
|
|
|
|
|
|
var payload = JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"target": "#ops-alerts",
|
|
|
|
|
"title": "Smoke test",
|
|
|
|
|
"body": "Sample body"
|
|
|
|
|
}
|
|
|
|
|
""")!;
|
|
|
|
|
|
|
|
|
|
var response = await PostAsync(client, "/api/v1/notify/channels/channel-test/test", payload);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
|
|
|
|
|
|
|
|
|
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
|
|
|
|
|
Assert.Equal("tenant-web", json["tenantId"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("channel-test", json["channelId"]?.GetValue<string>());
|
|
|
|
|
Assert.NotNull(json["queuedAt"]);
|
|
|
|
|
Assert.NotNull(json["traceId"]);
|
|
|
|
|
|
|
|
|
|
var preview = json["preview"]?.AsObject();
|
|
|
|
|
Assert.NotNull(preview);
|
|
|
|
|
Assert.Equal("#ops-alerts", preview? ["target"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("Smoke test", preview? ["title"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("Sample body", preview? ["body"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("Sample body"))).ToLowerInvariant();
|
|
|
|
|
Assert.Equal(expectedHash, preview? ["bodyHash"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
var metadata = json["metadata"] as JsonObject;
|
|
|
|
|
Assert.NotNull(metadata);
|
|
|
|
|
Assert.Equal("#ops-alerts", metadata?["target"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("slack", metadata?["channelType"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("fallback", metadata?["previewProvider"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal(json["traceId"]?.GetValue<string>(), metadata?["traceId"]?.GetValue<string>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ChannelTestSendHonoursRateLimit()
|
|
|
|
|
{
|
|
|
|
|
using var limitedFactory = _factory.WithWebHostBuilder(builder =>
|
|
|
|
|
{
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:tokenLimit", "1");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:tokensPerPeriod", "1");
|
|
|
|
|
builder.UseSetting("notify:api:rateLimits:testSend:queueLimit", "0");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var client = limitedFactory.CreateClient();
|
|
|
|
|
|
|
|
|
|
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
|
|
|
|
channelPayload["channelId"] = "channel-rate-limit";
|
|
|
|
|
channelPayload["tenantId"] = "tenant-web";
|
|
|
|
|
channelPayload["config"]! ["target"] = "#ops-alerts";
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
|
|
|
|
|
|
|
|
|
var payload = JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"body": "First"
|
|
|
|
|
}
|
|
|
|
|
""")!;
|
|
|
|
|
|
|
|
|
|
var first = await PostAsync(client, "/api/v1/notify/channels/channel-rate-limit/test", payload);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);
|
|
|
|
|
|
|
|
|
|
var secondRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/channels/channel-rate-limit/test")
|
|
|
|
|
{
|
|
|
|
|
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var second = await SendAsync(client, secondRequest);
|
|
|
|
|
Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode);
|
|
|
|
|
Assert.NotNull(second.Headers.RetryAfter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ChannelTestSendUsesRegisteredProvider()
|
|
|
|
|
{
|
|
|
|
|
var providerName = typeof(FakeSlackTestProvider).FullName!;
|
|
|
|
|
|
|
|
|
|
using var providerFactory = _factory.WithWebHostBuilder(builder =>
|
|
|
|
|
{
|
|
|
|
|
builder.ConfigureServices(services =>
|
|
|
|
|
{
|
|
|
|
|
services.AddSingleton<INotifyChannelTestProvider, FakeSlackTestProvider>();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var client = providerFactory.CreateClient();
|
|
|
|
|
|
|
|
|
|
var channelPayload = LoadSample("notify-channel@1.sample.json");
|
|
|
|
|
channelPayload["channelId"] = "channel-provider";
|
|
|
|
|
channelPayload["tenantId"] = "tenant-web";
|
|
|
|
|
channelPayload["config"]! ["target"] = "#ops-alerts";
|
|
|
|
|
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
|
|
|
|
|
|
|
|
|
|
var payload = JsonNode.Parse("""
|
|
|
|
|
{
|
|
|
|
|
"target": "#ops-alerts",
|
|
|
|
|
"title": "Provider Title",
|
|
|
|
|
"summary": "Provider Summary"
|
|
|
|
|
}
|
|
|
|
|
""")!;
|
|
|
|
|
|
|
|
|
|
var response = await PostAsync(client, "/api/v1/notify/channels/channel-provider/test", payload);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
|
|
|
|
|
|
|
|
|
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
|
|
|
|
|
var preview = json["preview"]?.AsObject();
|
|
|
|
|
Assert.NotNull(preview);
|
|
|
|
|
Assert.Equal("#ops-alerts", preview?["target"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("Provider Title", preview?["title"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("{\"provider\":\"fake\"}", preview?["body"]?.GetValue<string>());
|
|
|
|
|
|
|
|
|
|
var metadata = json["metadata"]?.AsObject();
|
|
|
|
|
Assert.NotNull(metadata);
|
|
|
|
|
Assert.Equal(providerName, metadata?["previewProvider"]?.GetValue<string>());
|
|
|
|
|
Assert.Equal("fake-provider", metadata?["provider.name"]?.GetValue<string>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class FakeSlackTestProvider : INotifyChannelTestProvider
|
|
|
|
|
{
|
|
|
|
|
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
|
|
|
|
|
|
|
|
|
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
var body = "{\"provider\":\"fake\"}";
|
|
|
|
|
var preview = NotifyDeliveryRendered.Create(
|
|
|
|
|
NotifyChannelType.Slack,
|
|
|
|
|
NotifyDeliveryFormat.Slack,
|
|
|
|
|
context.Target,
|
|
|
|
|
context.Request.Title ?? "Provider Title",
|
|
|
|
|
body,
|
|
|
|
|
context.Request.Summary ?? "Provider Summary",
|
|
|
|
|
context.Request.TextBody,
|
|
|
|
|
context.Request.Locale,
|
|
|
|
|
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
|
|
|
|
context.Request.Attachments);
|
|
|
|
|
|
|
|
|
|
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
|
|
|
{
|
|
|
|
|
["provider.name"] = "fake-provider"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static JsonNode LoadSample(string fileName)
|
|
|
|
|
{
|
|
|
|
|
var path = Path.Combine(AppContext.BaseDirectory, fileName);
|
|
|
|
|
if (!File.Exists(path))
|
|
|
|
|
{
|
|
|
|
|
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return JsonNode.Parse(File.ReadAllText(path)) ?? throw new InvalidOperationException("Sample JSON null.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<JsonArray?> GetJsonArrayAsync(HttpClient client, string path, bool useOperatorToken)
|
|
|
|
|
{
|
|
|
|
|
var response = await SendAsync(client, HttpMethod.Get, path, useOperatorToken);
|
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
|
|
|
return JsonNode.Parse(content) as JsonArray;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<JsonObject?> GetJsonObjectAsync(HttpClient client, string path, bool useOperatorToken)
|
|
|
|
|
{
|
|
|
|
|
var response = await SendAsync(client, HttpMethod.Get, path, useOperatorToken);
|
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
|
|
|
return JsonNode.Parse(content) as JsonObject;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<HttpResponseMessage> PostAsync(HttpClient client, string path, JsonNode payload, bool useOperatorToken = true)
|
|
|
|
|
{
|
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Post, path)
|
|
|
|
|
{
|
|
|
|
|
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var response = await SendAsync(client, request, useOperatorToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
|
var authHeader = response.Headers.WwwAuthenticate.ToString();
|
|
|
|
|
throw new InvalidOperationException($"Request to {path} failed with {(int)response.StatusCode} {response.StatusCode}: {body} (WWW-Authenticate: {authHeader})");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task<HttpResponseMessage> PostAsync(HttpClient client, string path, JsonNode payload)
|
|
|
|
|
=> PostAsync(client, path, payload, useOperatorToken: true);
|
|
|
|
|
|
|
|
|
|
private async Task DeleteAsync(HttpClient client, string path)
|
|
|
|
|
{
|
|
|
|
|
var response = await SendAsync(client, HttpMethod.Delete, path);
|
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, bool useOperatorToken = true)
|
|
|
|
|
=> SendAsync(client, new HttpRequestMessage(method, path), useOperatorToken);
|
|
|
|
|
|
|
|
|
|
private Task<HttpResponseMessage> SendAsync(HttpClient client, HttpRequestMessage request, bool useOperatorToken = true)
|
|
|
|
|
{
|
|
|
|
|
request.Headers.Add("X-StellaOps-Tenant", "tenant-web");
|
|
|
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", useOperatorToken ? _operatorToken : _viewerToken);
|
|
|
|
|
return client.SendAsync(request);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string CreateToken(params string[] scopes)
|
|
|
|
|
{
|
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
|
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey));
|
|
|
|
|
var claims = new List<System.Security.Claims.Claim>
|
|
|
|
|
{
|
|
|
|
|
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "integration-test")
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
foreach (var scope in scopes)
|
|
|
|
|
{
|
|
|
|
|
claims.Add(new System.Security.Claims.Claim("scope", scope));
|
|
|
|
|
claims.Add(new System.Security.Claims.Claim("http://schemas.microsoft.com/identity/claims/scope", scope));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var descriptor = new SecurityTokenDescriptor
|
|
|
|
|
{
|
|
|
|
|
Issuer = Issuer,
|
|
|
|
|
Audience = Audience,
|
|
|
|
|
Expires = DateTime.UtcNow.AddMinutes(10),
|
|
|
|
|
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
|
|
|
|
|
Subject = new System.Security.Claims.ClaimsIdentity(claims)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var token = handler.CreateToken(descriptor);
|
|
|
|
|
return handler.WriteToken(token);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|