diff --git a/.devcontainer/post-create-script.sh b/.devcontainer/post-create-script.sh index bc600173d..c590d0937 100755 --- a/.devcontainer/post-create-script.sh +++ b/.devcontainer/post-create-script.sh @@ -17,6 +17,9 @@ wget https://aka.ms/downloadazcopy-v10-linux tar -xvf downloadazcopy-v10-linux sudo cp ./azcopy_linux_amd64_*/azcopy /usr/bin/ +# Install Azurite +sudo npm install -g azurite + # Restore rust dependencies echo "Restoring rust dependencies" cargo install cargo-audit cargo-license # requirements if you want to run ci/agent.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57c9a14e2..8873a7e95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -275,6 +275,7 @@ jobs: cd src/ApiService/ dotnet restore --locked-mode dotnet tool restore + sudo npm install -g azurite - name: Check Formatting run: | cd src/ApiService/ @@ -286,8 +287,10 @@ jobs: - name: Test & Collect coverage info run: | cd src/ApiService/ - dotnet test --no-restore --collect:"XPlat Code Coverage" + azurite --silent & + dotnet test --no-restore --collect:"XPlat Code Coverage" --filter 'Category!=Integration' dotnet tool run reportgenerator -reports:Tests/TestResults/*/coverage.cobertura.xml -targetdir:coverage -reporttypes:MarkdownSummary + kill %1 cat coverage/*.md > $GITHUB_STEP_SUMMARY - name: copy artifacts run: | diff --git a/src/ApiService/ApiService/ServiceConfiguration.cs b/src/ApiService/ApiService/ServiceConfiguration.cs index 6fb236432..4c16f5d17 100644 --- a/src/ApiService/ApiService/ServiceConfiguration.cs +++ b/src/ApiService/ApiService/ServiceConfiguration.cs @@ -38,6 +38,11 @@ public interface IServiceConfig { public string? OneFuzzTelemetry { get; } public string OneFuzzVersion { get; } + + // Prefix to add to the name of any tables created. This allows + // multiple instances to run against the same storage account, which + // is useful for things like integration testing. + public string OneFuzzTablePrefix { get; } } public class ServiceConfiguration : IServiceConfig { @@ -82,4 +87,5 @@ public class ServiceConfiguration : IServiceConfig { public string OneFuzzVersion { get => Environment.GetEnvironmentVariable("ONEFUZZ_VERSION") ?? "0.0.0"; } public string OneFuzzNodeDisposalStrategy { get => Environment.GetEnvironmentVariable("ONEFUZZ_NODE_DISPOSAL_STRATEGY") ?? "scale_in"; } + public string OneFuzzTablePrefix => ""; // in production we never prefix the tables } diff --git a/src/ApiService/ApiService/onefuzzlib/Containers.cs b/src/ApiService/ApiService/onefuzzlib/Containers.cs index bf7ea253f..5b7b933b1 100644 --- a/src/ApiService/ApiService/onefuzzlib/Containers.cs +++ b/src/ApiService/ApiService/onefuzzlib/Containers.cs @@ -45,7 +45,7 @@ public class Containers : IContainers { if (client is null) return null; - return new Uri($"{GetUrl(client.AccountName)}{container}/{name}"); + return new Uri($"{_storage.GetBlobEndpoint(client.AccountName)}{container}/{name}"); } public async Async.Task GetBlob(Container container, string name, StorageType storageType) { @@ -93,14 +93,10 @@ public class Containers : IContainers { return null; } var storageKeyCredential = new StorageSharedKeyCredential(accountName, accountKey); - var accountUrl = GetUrl(accountName); + var accountUrl = _storage.GetBlobEndpoint(accountName); return new BlobServiceClient(accountUrl, storageKeyCredential); } - private static Uri GetUrl(string accountName) { - return new Uri($"https://{accountName}.blob.core.windows.net/"); - } - public async Async.Task GetFileSasUrl(Container container, string name, StorageType storageType, BlobSasPermissions permissions, TimeSpan? duration = null) { var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container.ContainerName} - {storageType}"); @@ -194,4 +190,3 @@ public class Containers : IContainers { return await client.GetBlobClient(name).ExistsAsync(); } } - diff --git a/src/ApiService/ApiService/onefuzzlib/Queue.cs b/src/ApiService/ApiService/onefuzzlib/Queue.cs index ebed45f6d..99a476ac0 100644 --- a/src/ApiService/ApiService/onefuzzlib/Queue.cs +++ b/src/ApiService/ApiService/onefuzzlib/Queue.cs @@ -51,9 +51,9 @@ public class Queue : IQueue { var accountId = _storage.GetPrimaryAccount(storageType); _log.Verbose($"getting blob container (account_id: {accountId})"); var (name, key) = await _storage.GetStorageAccountNameAndKey(accountId); - var accountUrl = new Uri($"https://{name}.queue.core.windows.net"); + var endpoint = _storage.GetQueueEndpoint(accountId); var options = new QueueClientOptions { MessageEncoding = QueueMessageEncoding.Base64 }; - return new QueueServiceClient(accountUrl, new StorageSharedKeyCredential(name, key), options); + return new QueueServiceClient(endpoint, new StorageSharedKeyCredential(name, key), options); } public async Task QueueObject(string name, T obj, StorageType storageType, TimeSpan? visibilityTimeout = null, TimeSpan? timeToLive = null) { diff --git a/src/ApiService/ApiService/onefuzzlib/Storage.cs b/src/ApiService/ApiService/onefuzzlib/Storage.cs index 1cf9c6d07..0db103bd2 100644 --- a/src/ApiService/ApiService/onefuzzlib/Storage.cs +++ b/src/ApiService/ApiService/onefuzzlib/Storage.cs @@ -13,6 +13,13 @@ public enum StorageType { public interface IStorage { public IEnumerable CorpusAccounts(); string GetPrimaryAccount(StorageType storageType); + + public Uri GetTableEndpoint(string accountId); + + public Uri GetQueueEndpoint(string accountId); + + public Uri GetBlobEndpoint(string accountId); + public Async.Task<(string?, string?)> GetStorageAccountNameAndKey(string accountId); public Async.Task GetStorageAccountNameAndKeyByName(string accountName); @@ -142,4 +149,13 @@ public class Storage : IStorage { throw new NotImplementedException(); } } + + public Uri GetTableEndpoint(string accountId) + => new($"https://{accountId}.table.core.windows.net/"); + + public Uri GetQueueEndpoint(string accountId) + => new($"https://{accountId}.queue.core.windows.net/"); + + public Uri GetBlobEndpoint(string accountId) + => new($"https://{accountId}.blob.core.windows.net/"); } diff --git a/src/ApiService/ApiService/onefuzzlib/orm/Orm.cs b/src/ApiService/ApiService/onefuzzlib/orm/Orm.cs index dd22ffe6d..7a3af922d 100644 --- a/src/ApiService/ApiService/onefuzzlib/orm/Orm.cs +++ b/src/ApiService/ApiService/onefuzzlib/orm/Orm.cs @@ -15,6 +15,14 @@ namespace ApiService.OneFuzzLib.Orm { Task> Insert(T entity); Task> Delete(T entity); + IAsyncEnumerable SearchAll(); + IAsyncEnumerable SearchByPartitionKey(string partitionKey); + IAsyncEnumerable SearchByRowKey(string rowKey); + IAsyncEnumerable SearchByTimeRange(DateTimeOffset min, DateTimeOffset max); + + // Allow using tuple to search. + IAsyncEnumerable SearchByTimeRange((DateTimeOffset min, DateTimeOffset max) range) + => SearchByTimeRange(range.min, range.max); } @@ -84,11 +92,15 @@ namespace ApiService.OneFuzzLib.Orm { } public async Task GetTableClient(string table, string? accountId = null) { + // TODO: do this less often, instead of once per request: + var tableName = _context.ServiceConfiguration.OneFuzzTablePrefix + table; + var account = accountId ?? _context.ServiceConfiguration.OneFuzzFuncStorage ?? throw new ArgumentNullException(nameof(accountId)); var (name, key) = await _context.Storage.GetStorageAccountNameAndKey(account); - var tableClient = new TableServiceClient(new Uri($"https://{name}.table.core.windows.net"), new TableSharedKeyCredential(name, key)); - await tableClient.CreateTableIfNotExistsAsync(table); - return tableClient.GetTableClient(table); + var endpoint = _context.Storage.GetTableEndpoint(account); + var tableClient = new TableServiceClient(endpoint, new TableSharedKeyCredential(name, key)); + await tableClient.CreateTableIfNotExistsAsync(tableName); + return tableClient.GetTableClient(tableName); } public async Task> Delete(T entity) { @@ -101,6 +113,19 @@ namespace ApiService.OneFuzzLib.Orm { return ResultVoid<(int, string)>.Ok(); } } + + public IAsyncEnumerable SearchAll() + => QueryAsync(null); + + public IAsyncEnumerable SearchByPartitionKey(string partitionKey) + => QueryAsync(Query.PartitionKey(partitionKey)); + + public IAsyncEnumerable SearchByRowKey(string rowKey) + => QueryAsync(Query.RowKey(rowKey)); + + public IAsyncEnumerable SearchByTimeRange(DateTimeOffset min, DateTimeOffset max) { + return QueryAsync(Query.TimeRange(min, max)); + } } diff --git a/src/ApiService/ApiService/onefuzzlib/orm/Queries.cs b/src/ApiService/ApiService/onefuzzlib/orm/Queries.cs index 7a5e58e02..7d47f1064 100644 --- a/src/ApiService/ApiService/onefuzzlib/orm/Queries.cs +++ b/src/ApiService/ApiService/onefuzzlib/orm/Queries.cs @@ -3,6 +3,20 @@ using Microsoft.OneFuzz.Service.OneFuzzLib.Orm; namespace ApiService.OneFuzzLib.Orm { public static class Query { + public static string PartitionKey(string partitionKey) { + // TODO: need to escape + return $"PartitionKey eq '{partitionKey}'"; + } + + public static string RowKey(string rowKey) { + // TODO: need to escape + return $"RowKey eq '{rowKey}'"; + } + + public static string SingleEntity(string partitionKey, string rowKey) { + // TODO: need to escape + return $"(PartitionKey eq '{partitionKey}') and (RowKey eq '{rowKey}')"; + } public static string Or(IEnumerable queries) { return string.Join(" or ", queries.Select(x => $"({x})")); @@ -28,7 +42,19 @@ namespace ApiService.OneFuzzLib.Orm { public static string EqualAnyEnum(string property, IEnumerable enums) where T : Enum { IEnumerable convertedEnums = enums.Select(x => JsonSerializer.Serialize(x, EntityConverter.GetJsonSerializerOptions()).Trim('"')); - return Query.EqualAny(property, convertedEnums); + return EqualAny(property, convertedEnums); + } + + public static string TimeRange(DateTimeOffset min, DateTimeOffset max) { + // NB: this uses the auto-populated Timestamp property, and will result in scanning + // TODO: should this be inclusive at the endpoints? + return $"Timestamp lt datetime'{max:o}' and Timestamp gt datetime'{min:o}'"; + } + + public static string StartsWith(string property, string prefix) { + var upperBound = prefix[..(prefix.Length - 1)] + (char)(prefix.Last() + 1); + // TODO: escaping + return $"{property} ge '{prefix}' and {property} lt '{upperBound}'"; } } } diff --git a/src/ApiService/Tests/Fakes/TestContext.cs b/src/ApiService/Tests/Fakes/TestContext.cs new file mode 100644 index 000000000..3cdc8e970 --- /dev/null +++ b/src/ApiService/Tests/Fakes/TestContext.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using Microsoft.OneFuzz.Service; +using Microsoft.OneFuzz.Service.OneFuzzLib.Orm; +using Async = System.Threading.Tasks; + +namespace Tests.Fakes; + + +// TestContext provides a minimal IOnefuzzContext implementation to allow running +// of functions as unit or integration tests. +public sealed class TestContext : IOnefuzzContext { + public TestContext(ILogTracer logTracer, IStorage storage, string tablePrefix, string accountId) { + ServiceConfiguration = new TestServiceConfiguration(tablePrefix, accountId); + + Storage = storage; + + RequestHandling = new RequestHandling(logTracer); + TaskOperations = new TaskOperations(logTracer, this); + NodeOperations = new NodeOperations(logTracer, this); + JobOperations = new JobOperations(logTracer, this); + NodeTasksOperations = new NodeTasksOperations(logTracer, this); + } + + public TestEvents Events { get; set; } = new(); + + // convenience method for test setup + public Async.Task InsertAll(params EntityBase[] objs) + => Async.Task.WhenAll( + objs.Select(x => x switch { + Task t => TaskOperations.Insert(t), + Node n => NodeOperations.Insert(n), + Job j => JobOperations.Insert(j), + NodeTasks nt => NodeTasksOperations.Insert(nt), + _ => throw new NotImplementedException($"Need to add an TestContext.InsertAll case for {x.GetType()} entities"), + })); + + // Implementations: + + IEvents IOnefuzzContext.Events => Events; + + public IServiceConfig ServiceConfiguration { get; } + + public IStorage Storage { get; } + + public IRequestHandling RequestHandling { get; } + + public ITaskOperations TaskOperations { get; } + public IJobOperations JobOperations { get; } + public INodeOperations NodeOperations { get; } + public INodeTasksOperations NodeTasksOperations { get; } + + // -- Remainder not implemented -- + + public IConfig Config => throw new System.NotImplementedException(); + + public IConfigOperations ConfigOperations => throw new System.NotImplementedException(); + + public IContainers Containers => throw new System.NotImplementedException(); + + public ICreds Creds => throw new System.NotImplementedException(); + + public IDiskOperations DiskOperations => throw new System.NotImplementedException(); + + public IExtensions Extensions => throw new System.NotImplementedException(); + + public IIpOperations IpOperations => throw new System.NotImplementedException(); + + + public ILogAnalytics LogAnalytics => throw new System.NotImplementedException(); + + public INodeMessageOperations NodeMessageOperations => throw new System.NotImplementedException(); + + public INotificationOperations NotificationOperations => throw new System.NotImplementedException(); + + public IPoolOperations PoolOperations => throw new System.NotImplementedException(); + + public IProxyForwardOperations ProxyForwardOperations => throw new System.NotImplementedException(); + + public IProxyOperations ProxyOperations => throw new System.NotImplementedException(); + + public IQueue Queue => throw new System.NotImplementedException(); + + public IReports Reports => throw new System.NotImplementedException(); + + public IReproOperations ReproOperations => throw new System.NotImplementedException(); + + public IScalesetOperations ScalesetOperations => throw new System.NotImplementedException(); + + public IScheduler Scheduler => throw new System.NotImplementedException(); + + public ISecretsOperations SecretsOperations => throw new System.NotImplementedException(); + + public IUserCredentials UserCredentials => throw new System.NotImplementedException(); + + public IVmOperations VmOperations => throw new System.NotImplementedException(); + + public IVmssOperations VmssOperations => throw new System.NotImplementedException(); + + public IWebhookMessageLogOperations WebhookMessageLogOperations => throw new System.NotImplementedException(); + + public IWebhookOperations WebhookOperations => throw new System.NotImplementedException(); + +} diff --git a/src/ApiService/Tests/Fakes/TestEvents.cs b/src/ApiService/Tests/Fakes/TestEvents.cs new file mode 100644 index 000000000..b11de543a --- /dev/null +++ b/src/ApiService/Tests/Fakes/TestEvents.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Microsoft.OneFuzz.Service; + +using Async = System.Threading.Tasks; + +namespace Tests.Fakes; + +public sealed class TestEvents : IEvents { + + public List Events { get; } = new(); + public List SignalREvents { get; } = new(); + + public void LogEvent(BaseEvent anEvent) { + Events.Add(anEvent); + } + + public Async.Task QueueSignalrEvent(EventMessage message) { + SignalREvents.Add(message); + return Async.Task.CompletedTask; + } + + public Async.Task SendEvent(BaseEvent anEvent) { + Events.Add(anEvent); + return Async.Task.CompletedTask; + } +} diff --git a/src/ApiService/Tests/Fakes/TestHttpRequestData.cs b/src/ApiService/Tests/Fakes/TestHttpRequestData.cs new file mode 100644 index 000000000..777e1ab14 --- /dev/null +++ b/src/ApiService/Tests/Fakes/TestHttpRequestData.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Security.Claims; +using Azure.Core.Serialization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Options; +using Moq; + +namespace Tests.Fakes; + +sealed class TestHttpRequestData : HttpRequestData { + private static readonly ObjectSerializer Serializer = + // we must use our shared JsonSerializerOptions to be able to serialize & deserialize polymorphic types + new JsonObjectSerializer(Microsoft.OneFuzz.Service.OneFuzzLib.Orm.EntityConverter.GetJsonSerializerOptions()); + + sealed class TestServices : IServiceProvider { + sealed class TestOptions : IOptions { + // WorkerOptions only has one setting: Serializer + public WorkerOptions Value => new() { Serializer = Serializer }; + } + + static readonly IOptions Options = new TestOptions(); + + public object? GetService(Type serviceType) { + if (serviceType == typeof(IOptions)) { + return Options; + } + + return null; + } + } + + private static FunctionContext NewFunctionContext() { + // mocking this out at the moment since there’s no way to create a subclass + var mock = new Mock(); + var services = new TestServices(); + mock.SetupGet(fc => fc.InstanceServices).Returns(services); + return mock.Object; + } + + public static TestHttpRequestData FromJson(string method, T obj) + => new(method, Serializer.Serialize(obj)); + + public TestHttpRequestData(string method, BinaryData body) + : base(NewFunctionContext()) { + Method = method; + _body = body; + } + + private readonly BinaryData _body; + + public override Stream Body => _body.ToStream(); + + public override HttpHeadersCollection Headers => throw new NotImplementedException(); + + public override IReadOnlyCollection Cookies => throw new NotImplementedException(); + + public override Uri Url => throw new NotImplementedException(); + + public override IEnumerable Identities => throw new NotImplementedException(); + + public override string Method { get; } + + public override HttpResponseData CreateResponse() + => new TestHttpResponseData(FunctionContext); +} + +sealed class TestHttpResponseData : HttpResponseData { + public TestHttpResponseData(FunctionContext functionContext) + : base(functionContext) { } + public override HttpStatusCode StatusCode { get; set; } + public override HttpHeadersCollection Headers { get; set; } = new(); + public override Stream Body { get; set; } = new MemoryStream(); + public override HttpCookies Cookies => throw new NotSupportedException(); +} diff --git a/src/ApiService/Tests/Fakes/TestServiceConfiguration.cs b/src/ApiService/Tests/Fakes/TestServiceConfiguration.cs new file mode 100644 index 000000000..47bb74b5f --- /dev/null +++ b/src/ApiService/Tests/Fakes/TestServiceConfiguration.cs @@ -0,0 +1,60 @@ +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.OneFuzz.Service; + +namespace Tests.Fakes; + +sealed class TestServiceConfiguration : IServiceConfig { + public TestServiceConfiguration(string tablePrefix, string accountId) { + OneFuzzTablePrefix = tablePrefix; + OneFuzzFuncStorage = accountId; + } + + public string OneFuzzTablePrefix { get; } + + public string? OneFuzzFuncStorage { get; } + + // -- Remainder not implemented -- + + public LogDestination[] LogDestinations { get => throw new System.NotImplementedException(); set => throw new System.NotImplementedException(); } + + public SeverityLevel LogSeverityLevel => throw new System.NotImplementedException(); + + public string? ApplicationInsightsAppId => throw new System.NotImplementedException(); + + public string? ApplicationInsightsInstrumentationKey => throw new System.NotImplementedException(); + + public string? AzureSignalRConnectionString => throw new System.NotImplementedException(); + + public string? AzureSignalRServiceTransportType => throw new System.NotImplementedException(); + + public string? AzureWebJobDisableHomePage => throw new System.NotImplementedException(); + + public string? AzureWebJobStorage => throw new System.NotImplementedException(); + + public string? DiagnosticsAzureBlobContainerSasUrl => throw new System.NotImplementedException(); + + public string? DiagnosticsAzureBlobRetentionDays => throw new System.NotImplementedException(); + + public string? MultiTenantDomain => throw new System.NotImplementedException(); + + public string? OneFuzzDataStorage => throw new System.NotImplementedException(); + + + public string? OneFuzzInstance => throw new System.NotImplementedException(); + + public string? OneFuzzInstanceName => throw new System.NotImplementedException(); + + public string? OneFuzzKeyvault => throw new System.NotImplementedException(); + + public string? OneFuzzMonitor => throw new System.NotImplementedException(); + + public string? OneFuzzOwner => throw new System.NotImplementedException(); + + public string OneFuzzNodeDisposalStrategy => throw new System.NotImplementedException(); + + public string? OneFuzzResourceGroup => throw new System.NotImplementedException(); + + public string? OneFuzzTelemetry => throw new System.NotImplementedException(); + + public string OneFuzzVersion => throw new System.NotImplementedException(); +} diff --git a/src/ApiService/Tests/Functions/_FunctionTestBase.cs b/src/ApiService/Tests/Functions/_FunctionTestBase.cs new file mode 100644 index 000000000..97670996a --- /dev/null +++ b/src/ApiService/Tests/Functions/_FunctionTestBase.cs @@ -0,0 +1,64 @@ +using System; +using ApiService.OneFuzzLib.Orm; +using Azure.Data.Tables; +using Microsoft.OneFuzz.Service; +using Tests.Fakes; +using Xunit.Abstractions; + +namespace Tests.Functions; + +// FunctionTestBase contains shared implementations for running +// functions against live Azure Storage or the Azurite emulator. +// +// To use this base class, derive an abstract class from this +// with all the tests defined in it. Then, from that class +// derive two non-abstract classes for XUnit to find: +// - one for Azurite +// - one for Azure Storage (marked with [Trait("Category", "Integration")]) +// +// See AgentEventsTests for an example. +public abstract class FunctionTestBase : IDisposable { + private readonly IStorage _storage; + + // each test will use a different table prefix so they don't interfere + // with each other - generate a prefix like t12345678 (table names must start with letter) + private readonly string _tablePrefix = "t" + Guid.NewGuid().ToString()[..8]; + + private readonly string _accountId; + + protected ILogTracer Logger { get; } + + protected TestContext CreateTestContext() => new(Logger, _storage, _tablePrefix, _accountId); + + public FunctionTestBase(ITestOutputHelper output, IStorage storage, string accountId) { + Logger = new TestLogTracer(output); + _storage = storage; + _accountId = accountId; + } + + public void Dispose() { + // TODO, a bit ugly, tidy this up: + // delete any tables we created during the run + if (_storage is Integration.AzureStorage storage) { + var accountName = storage.AccountName; + var accountKey = storage.AccountKey; + if (accountName is not null && accountKey is not null) { + // we are running against live storage + var tableClient = new TableServiceClient( + _storage.GetTableEndpoint(accountName), + new TableSharedKeyCredential(accountName, accountKey)); + + var tablesToDelete = tableClient.Query(filter: Query.StartsWith("TableName", _tablePrefix)); + foreach (var table in tablesToDelete) { + try { + tableClient.DeleteTable(table.Name); + Logger.Info($"cleaned up table {table.Name}"); + } catch (Exception ex) { + // swallow any exceptions: this is a best-effort attempt to cleanup + Logger.Exception(ex, "error deleting table at end of test"); + } + } + } + } + } +} diff --git a/src/ApiService/Tests/Integration/AzureStorage.cs b/src/ApiService/Tests/Integration/AzureStorage.cs new file mode 100644 index 000000000..b69c0fdf9 --- /dev/null +++ b/src/ApiService/Tests/Integration/AzureStorage.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.OneFuzz.Service; + +using Async = System.Threading.Tasks; + +namespace Tests.Integration; + +// This exists solely to allow use of a fixed storage account in integration tests +// against live Azure Storage. +sealed class AzureStorage : IStorage { + public static IStorage FromEnvironment() { + var accountName = Environment.GetEnvironmentVariable("AZURE_ACCOUNT_NAME"); + var accountKey = Environment.GetEnvironmentVariable("AZURE_ACCOUNT_KEY"); + + if (accountName is null) { + throw new Exception("AZURE_ACCOUNT_NAME must be set in environment to run integration tests"); + } + + if (accountKey is null) { + throw new Exception("AZURE_ACCOUNT_KEY must be set in environment to run integration tests"); + } + + return new AzureStorage(accountName, accountKey); + } + + public string? AccountName { get; } + public string? AccountKey { get; } + + public AzureStorage(string? accountName, string? accountKey) { + AccountName = accountName; + AccountKey = accountKey; + } + + public IEnumerable CorpusAccounts() { + throw new System.NotImplementedException(); + } + + public IEnumerable GetAccounts(StorageType storageType) { + throw new System.NotImplementedException(); + } + + public string GetPrimaryAccount(StorageType storageType) { + throw new System.NotImplementedException(); + } + + public Task<(string?, string?)> GetStorageAccountNameAndKey(string accountId) + => Async.Task.FromResult((AccountName, AccountKey)); + + public Task GetStorageAccountNameAndKeyByName(string accountName) { + throw new System.NotImplementedException(); + } + + public Uri GetTableEndpoint(string accountId) + => new($"https://{AccountName}.table.core.windows.net/"); + + public Uri GetQueueEndpoint(string accountId) + => new($"https://{AccountName}.queue.core.windows.net/"); + + public Uri GetBlobEndpoint(string accountId) + => new($"https://{AccountName}.blob.core.windows.net/"); +} diff --git a/src/ApiService/Tests/Integration/AzuriteStorage.cs b/src/ApiService/Tests/Integration/AzuriteStorage.cs new file mode 100644 index 000000000..be04dab86 --- /dev/null +++ b/src/ApiService/Tests/Integration/AzuriteStorage.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.OneFuzz.Service; + +using Async = System.Threading.Tasks; + +namespace Tests.Integration; + +sealed class AzuriteStorage : IStorage { + public Uri GetBlobEndpoint(string accountId) + => new($"http://127.0.0.1:10000/{accountId}"); + + public Uri GetQueueEndpoint(string accountId) + => new($"http://127.0.0.1:10001/{accountId}"); + + public Uri GetTableEndpoint(string accountId) + => new($"http://127.0.0.1:10002/{accountId}"); + + // This is the fixed account key used by Azurite (derived from devstorage emulator); + // https://docs.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string#configure-a-connection-string-for-azurite + const string AccountKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="; + + public Task<(string?, string?)> GetStorageAccountNameAndKey(string accountId) + => Async.Task.FromResult<(string?, string?)>((accountId, AccountKey)); + + public Task GetStorageAccountNameAndKeyByName(string accountName) { + throw new System.NotImplementedException(); + } + + public IEnumerable CorpusAccounts() { + throw new System.NotImplementedException(); + } + + public IEnumerable GetAccounts(StorageType storageType) { + throw new System.NotImplementedException(); + } + + public string GetPrimaryAccount(StorageType storageType) { + throw new System.NotImplementedException(); + } +} diff --git a/src/ApiService/Tests/QueryTest.cs b/src/ApiService/Tests/QueryTest.cs index dd1091366..030dfbea9 100644 --- a/src/ApiService/Tests/QueryTest.cs +++ b/src/ApiService/Tests/QueryTest.cs @@ -1,4 +1,5 @@ using System; +using ApiService.OneFuzzLib.Orm; using Microsoft.OneFuzz.Service; using Xunit; @@ -34,5 +35,11 @@ namespace Tests { excludeUpdateScheduled: true); Assert.Equal("((pool_id eq '3b0426d3-9bde-4ae8-89ac-4edf0d3b3618')) and ((scaleset_id eq '4c96dd6b-9bdb-4758-9720-1010c244fa4b')) and (((state eq 'free') or (state eq 'done') or (state eq 'ready'))) and (reimage_requested eq false) and (delete_requested eq false) and (not (version eq '1.2.3'))", query7); } + + [Fact] + public void StartsWithTests() { + var query = Query.StartsWith("prop", "prefix"); + Assert.Equal("prop ge 'prefix' and prop lt 'prefiy'", query); + } } } diff --git a/src/ApiService/Tests/TestLogTracer.cs b/src/ApiService/Tests/TestLogTracer.cs new file mode 100644 index 000000000..642a64120 --- /dev/null +++ b/src/ApiService/Tests/TestLogTracer.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using Microsoft.OneFuzz.Service; +using Xunit.Abstractions; + +namespace Tests; + +sealed class TestLogTracer : ILogTracer { + private readonly ITestOutputHelper _output; + + public TestLogTracer(ITestOutputHelper output) + => _output = output; + + private readonly Dictionary _tags = new(); + public IReadOnlyDictionary Tags => _tags; + + public void Critical(string message) { + _output.WriteLine($"[Critical] {message}"); + } + + public void Error(string message) { + _output.WriteLine($"[Error] {message}"); + } + + public void Event(string evt, IReadOnlyDictionary? metrics) { + // TODO: metrics + _output.WriteLine($"[Event] [{evt}]"); + } + + public void Exception(Exception ex, string message = "", IReadOnlyDictionary? metrics = null) { + // TODO: metrics + _output.WriteLine($"[Error] {message} {ex}"); + } + + public void ForceFlush() { + // nothing to do + } + + public void Info(string message) { + _output.WriteLine($"[Info] {message}"); + } + + public void Verbose(string message) { + _output.WriteLine($"[Verbose] {message}"); + } + + public void Warning(string message) { + _output.WriteLine($"[Warning] {message}"); + } + + public ILogTracer WithHttpStatus((int, string) status) { + return this; // TODO? + } + + public ILogTracer WithTag(string k, string v) { + return this; // TODO? + } + + public ILogTracer WithTags(IEnumerable<(string, string)>? tags) { + return this; // TODO? + } +}