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
70 changes: 0 additions & 70 deletions .github/workflows/codeql-analysis.yml

This file was deleted.

114 changes: 114 additions & 0 deletions AzureSearchEmulator.IntegrationTests/DashboardIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Net;
using Azure.Search.Documents.Indexes.Models;
using Xunit;

namespace AzureSearchEmulator.IntegrationTests;

/// <summary>
/// Integration tests for the dashboard and health endpoint (issue #90), run against a
/// containerized emulator.
/// </summary>
/// <remarks>
/// What these are really guarding is the coexistence. Serving a UI at <c>/</c> meant taking the
/// root away from OData's service document and adding anti-forgery middleware in front of every
/// route, and both of those are the kind of change that works on the developer's machine and
/// breaks the API surface somewhere else. So the assertions come in pairs: the dashboard and
/// <c>/health</c> answer, and the endpoints the Azure SDK depends on still answer as they did.
/// </remarks>
public class DashboardIntegrationTests(EmulatorFactory factory)
: IClassFixture<EmulatorFactory>
{
[Fact]
public async Task Root_ServesTheDashboard()
{
await factory.WaitUntilServingAsync();
using var client = factory.CreateHttpClient();

var response = await client.GetAsync("/", TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType);

var html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);

// Prerendered server-side, so the status is in the markup rather than arriving later over
// the circuit — which is also what makes it assertable without driving a browser.
Assert.Contains("Azure Search Emulator", html);
Assert.Contains("Health checks", html);
Assert.Contains("Running normally", html);
}

[Fact]
public async Task Dashboard_StylesheetIsServed()
{
await factory.WaitUntilServingAsync();
using var client = factory.CreateHttpClient();

var response = await client.GetAsync("/css/dashboard.css", TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/css", response.Content.Headers.ContentType?.MediaType);
}

/// <remarks>
/// The container has a writable indexes volume and no malformed definitions, so a healthy
/// answer here is the real thing rather than a check that cannot fail — the unit tests cover
/// the failing directions.
/// </remarks>
[Fact]
public async Task Health_ReportsHealthy()
{
await factory.WaitUntilServingAsync();
using var client = factory.CreateHttpClient();

var response = await client.GetAsync("/health", TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Healthy", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}

/// <remarks>
/// The dashboard took <c>/</c> from OData's service document, which sits on the same controller
/// as <c>$metadata</c>. Only the former was meant to go.
/// </remarks>
[Fact]
public async Task Metadata_IsStillServed()
{
await factory.WaitUntilServingAsync();
using var client = factory.CreateHttpClient();

var response = await client.GetAsync("/$metadata", TestContext.Current.CancellationToken);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("SearchIndex", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}

/// <remarks>
/// Anti-forgery middleware runs ahead of the API routes now. It is supposed to ignore requests
/// that carry no token, but a misconfiguration would reject exactly the unauthenticated writes
/// every SDK client makes — so this drives one through the SDK rather than raw HTTP.
/// </remarks>
[Fact]
public async Task ApiWrites_AreNotBlockedByAntiforgery()
{
var client = factory.CreateSearchIndexClient();
const string indexName = "test-dashboard-antiforgery";

var index = new SearchIndex(indexName)
{
Fields = { new SearchField("id", SearchFieldDataType.String) { IsKey = true } }
};

try
{
await client.CreateIndexAsync(index, TestContext.Current.CancellationToken);

var fetched = await client.GetIndexAsync(indexName, TestContext.Current.CancellationToken);
Assert.Equal(indexName, fetched.Value.Name);
}
finally
{
await client.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
}
}
}
183 changes: 183 additions & 0 deletions AzureSearchEmulator.UnitTests/HealthCheckTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
using System.Text.Json;
using AzureSearchEmulator.Health;
using AzureSearchEmulator.Models;
using AzureSearchEmulator.Repositories;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Xunit;

namespace AzureSearchEmulator.UnitTests;

/// <summary>
/// Tests for the health checks behind the dashboard and <c>/health</c> (issue #90).
/// </summary>
/// <remarks>
/// These run against real directories rather than an abstraction over the filesystem, because the
/// conditions the checks exist to catch — a directory that is missing, or present but not writable
/// — are filesystem states, and a mocked <c>IFileSystem</c> reporting them would only be testing
/// the mock's own opinion of what a failed write looks like.
/// </remarks>
public class HealthCheckTests : IDisposable
{
private readonly string _root =
Path.Join(Path.GetTempPath(), $"azsearchemu-health-{Guid.NewGuid():N}");

public void Dispose()
{
if (!Directory.Exists(_root))
{
return;
}

// A directory the writability test chmod'ed has to be made writable again or the delete
// fails and leaks the temp folder. Nothing is chmod'ed on Windows, where that test skips.
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(_root,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}

Directory.Delete(_root, recursive: true);
}

private IndexStorageHealthCheck CreateStorageCheck(string directory) =>
new(Options.Create(new EmulatorOptions { IndexesDirectory = directory }));

private static Task<HealthCheckResult> Run(IHealthCheck check) =>
check.CheckHealthAsync(new HealthCheckContext());

[Fact]
public async Task IndexStorage_WithWritableDirectory_IsHealthy()
{
Directory.CreateDirectory(_root);

var result = await Run(CreateStorageCheck(_root));

Assert.Equal(HealthStatus.Healthy, result.Status);
Assert.Equal(_root, result.Data["indexesDirectory"]);
}

/// <remarks>
/// The emulator creates the indexes directory on first use, so a fresh install has none. That
/// is the normal state rather than a fault, and reporting it as unhealthy would mean every
/// first run of the tool showed a red dashboard.
/// </remarks>
[Fact]
public async Task IndexStorage_WhenDirectoryDoesNotYetExist_IsHealthy()
{
Directory.CreateDirectory(_root);
var notYetCreated = Path.Join(_root, "indexes");

var result = await Run(CreateStorageCheck(notYetCreated));

Assert.Equal(HealthStatus.Healthy, result.Status);
}

[Fact]
public async Task IndexStorage_WhenNeitherDirectoryNorParentExists_IsUnhealthy()
{
var unreachable = Path.Join(_root, "no", "such", "path");

var result = await Run(CreateStorageCheck(unreachable));

Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.Contains("cannot be created", result.Description);
}

/// <remarks>
/// The case the check is really for: a Docker volume mounted read-only, or a tool launched from
/// a directory the user cannot write to. Without it the failure first appears as a 500 from
/// whichever indexing request happened to run.
/// </remarks>
[Fact]
public async Task IndexStorage_WhenDirectoryIsNotWritable_IsUnhealthy()
{
// Written as an if rather than Assert.SkipWhen so the platform analyzer can see that
// SetUnixFileMode is only reached off Windows; Skip throws, which it cannot narrow on.
if (OperatingSystem.IsWindows())
{
Assert.Skip("Unix file modes are the mechanism being used to make the directory read-only.");
return;
}

Assert.SkipWhen(Environment.UserName == "root",
"root writes to a read-only directory regardless of its mode.");

Directory.CreateDirectory(_root);
File.SetUnixFileMode(_root, UnixFileMode.UserRead | UnixFileMode.UserExecute);

var result = await Run(CreateStorageCheck(_root));

Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.Contains("not writable", result.Description);
}

[Fact]
public async Task IndexStorage_LeavesNoProbeFileBehind()
{
Directory.CreateDirectory(_root);

await Run(CreateStorageCheck(_root));

Assert.Empty(Directory.GetFileSystemEntries(_root));
}

[Fact]
public async Task IndexDefinitions_WithReadableDefinitions_ReportsTheCount()
{
Directory.CreateDirectory(_root);
WriteIndex("products");
WriteIndex("people");

var result = await Run(new IndexDefinitionsHealthCheck(CreateRepository()));

Assert.Equal(HealthStatus.Healthy, result.Status);
Assert.Equal(2, result.Data["indexCount"]);
}

/// <remarks>
/// Degraded rather than Unhealthy: the emulator still serves every index that parses, and the
/// checks aggregate into one overall status, so failing outright would claim the whole service
/// is down over one hand-edited file.
/// </remarks>
[Fact]
public async Task IndexDefinitions_WithAMalformedDefinition_IsDegraded()
{
Directory.CreateDirectory(_root);
WriteIndex("products");
File.WriteAllText(Path.Join(_root, "broken.index.json"), "{ this is not json");

var result = await Run(new IndexDefinitionsHealthCheck(CreateRepository()));

Assert.Equal(HealthStatus.Degraded, result.Status);
}

[Fact]
public async Task IndexDefinitions_WithNoIndexes_IsHealthy()
{
Directory.CreateDirectory(_root);

var result = await Run(new IndexDefinitionsHealthCheck(CreateRepository()));

Assert.Equal(HealthStatus.Healthy, result.Status);
Assert.Equal(0, result.Data["indexCount"]);
}

private ISearchIndexRepository CreateRepository() =>
new FileSearchIndexRepository(
new JsonSerializerOptions(JsonSerializerDefaults.Web),
Options.Create(new EmulatorOptions { IndexesDirectory = _root }));

private void WriteIndex(string name)
{
var index = new SearchIndex
{
Name = name,
Fields = [new SearchField { Name = "Id", Type = "Edm.String", Key = true }]
};

File.WriteAllText(
Path.Join(_root, $"{name}.index.json"),
JsonSerializer.Serialize(index, new JsonSerializerOptions(JsonSerializerDefaults.Web)));
}
}
21 changes: 21 additions & 0 deletions AzureSearchEmulator/Components/App.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@* Root component (issue #90). Hand-written rather than taken from the Blazor template: the
template's version pulls in bootstrap, the sample stylesheet and the blazor error UI, none of
which this dashboard uses. *@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="css/dashboard.css" />
<link rel="icon" href="data:," />
<HeadOutlet />
</head>

<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>

</html>
Loading