Skip to content
Merged
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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,32 @@ Errors are shown in the UI and saving is blocked until they are resolved.

## Security

The `/config` route is not protected by default:
Outside the Development environment the editor and its API answer `403` until you decide who may use them:

```csharp
app.UseConfigWay()
.RequireAuthorization(policy => policy.RequireRole("Admin"));

// or, for a public demo:
app.UseConfigWay().AllowAnonymous();
```

A fallback authorization policy counts as a decision too. An editor nobody decided on is reported in the log at startup.

## Way.Shell

The editor runs in [Way.Shell](https://github.com/kododo-dev/WayShell), the UI shell shared by the Kododo Way libraries: the layout, theme, language and session handling come from the shell, and with other Ways in the same app it can also be opened from the shell's panel:

```csharp
builder.Services.AddWayShell(o =>
{
o.Title = "Acme";
o.HomeUrl = "~/";
o.User = ctx => ctx.User.Identity?.IsAuthenticated == true ? new ShellUser(ctx.User.Identity.Name!) { SignOutUrl = "~/logout" } : null;
});

app.UseConfigWay("/config").RequireAuthorization("Admin");
app.UseWayShell("/admin").RequireAuthorization(); // every Way the user may open
```

> Use `IOptionsSnapshot<T>` or `IOptionsMonitor<T>` in your code to always get the latest values after a reload.
Expand Down
3 changes: 2 additions & 1 deletion src/ConfigWay.Demo.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
return Results.Content(html, "text/html");
});

app.UseConfigWay();
// The public demo: anyone may edit its settings.
app.UseConfigWay().AllowAnonymous();

app.Run();
100 changes: 100 additions & 0 deletions src/ConfigWay.Demo.Web/e2e/check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Browser scenarios of the editor in Way.Shell, against the running demo (Production, AllowAnonymous):
// node check.mjs http://localhost:5089 [chrome|msedge|firefox|webkit]
// Needs the playwright package (npm i --no-save playwright); firefox and webkit need `npx playwright install`.
import { chromium, firefox, webkit } from 'playwright';

const base = process.argv[2].replace(/\/$/, '');
const which = process.argv[3] ?? 'chrome';
const browser = which === 'firefox' ? await firefox.launch() : which === 'webkit' ? await webkit.launch() : await chromium.launch({ channel: which });
const results = [];
const record = (name, ok, detail = '') => results.push({ name, ok, detail });
const visible = (l) => l.first().waitFor({ timeout: 10000 }).then(() => true, () => false);

async function open(options = {}) {
const context = await browser.newContext({ locale: 'en-US', colorScheme: 'light', ...options });
const page = await context.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => { if (m.type() === 'error' && !m.text().startsWith('Failed to load resource')) errors.push(m.text()); });
page.on('response', (r) => { if (r.status() >= 400 && !r.url().endsWith('/favicon.ico')) errors.push(`${r.status()} ${r.url()}`); });
return { context, page, errors };
}

{
const { context, page, errors } = await open();
await page.goto(`${base}/config/`);
record('editor opens in the shell', await visible(page.getByRole('heading', { name: 'All Settings' })));
record('title from the Way', (await page.title()) === 'Configuration', await page.title());
const menu = page.getByTestId('way-menu');
record('sections in the shell menu', await visible(menu.getByText('Branding')));

const font = await page.locator('main h1').first().evaluate((e) => getComputedStyle(e).fontFamily); // Typography h1, no font of its own
record("ConfigWay's monospace theme in its area", /IBM Plex Mono/.test(font), font);
const shellTitleFont = await page.locator('nav, [data-testid="way-menu"]').first().evaluate(() =>
getComputedStyle(document.querySelector('.MuiDrawer-paper p')).fontFamily);
record("shell's own font in the shell", !/IBM Plex Mono/.test(shellTitleFont), shellTitleFont);

await menu.getByText('Branding').click();
record('section from the menu', (await visible(page.getByRole('heading', { name: 'Branding' }))) && page.url().endsWith('#/section/Branding'), page.url());
await menu.getByText('Smtp').click();
record('second section from the menu (paths from the Way root)', page.url().endsWith('#/section/Smtp'), page.url());
await menu.getByText('Branding').click();
await page.getByRole('heading', { name: 'Branding' }).waitFor();

// The fields are textareas; find Company Name by its value.
const fieldByValue = async (value) => {
await page.locator('main textarea').first().waitFor();
const index = await page.locator('main textarea').evaluateAll((els, v) => els.findIndex((e) => e.value === v), value);
return index >= 0 ? page.locator('main textarea').nth(index) : null;
};
const field = await fieldByValue('Acme Corp');
record('field with its value', !!field);
if (field) {
await field.fill('Acme Pilot');
await Promise.all([
page.waitForResponse((r) => r.url().endsWith('/config/api/UpdateConfiguration')),
page.getByRole('button', { name: 'Save' }).click(),
]);
await page.reload();
const saved = await fieldByValue('Acme Pilot');
record('saving through the shell API client', !!saved);
if (saved) {
await saved.fill('Acme Corp');
await Promise.all([
page.waitForResponse((r) => r.url().endsWith('/config/api/UpdateConfiguration')),
page.getByRole('button', { name: 'Save' }).click(),
]);
}
}

await page.goto(`${base}/config/#/section/Nope`);
await page.waitForTimeout(500);
record('unknown section goes to the Way root', page.url().endsWith('/config/#/'), page.url());

await page.getByRole('button', { name: 'Language' }).click();
await page.getByRole('menuitem', { name: 'Polski' }).click();
record('language from the shell', await visible(page.getByRole('heading', { name: 'Wszystkie ustawienia' })));

await page.getByRole('button', { name: 'Ciemny motyw' }).click();
const header = await page.locator('main header').first().evaluate((e) => getComputedStyle(e).backgroundColor);
record("ConfigWay's dark paper in its area", header === 'rgb(36, 36, 36)', header);
record('no errors', errors.length === 0, errors.join(' | '));
await context.close();
}

{
const { context, page } = await open({ viewport: { width: 390, height: 800 } });
await page.goto(`${base}/config/`);
await visible(page.getByRole('heading', { name: 'All Settings' }));
await page.getByRole('button', { name: 'Open menu' }).click();
await page.getByTestId('way-menu').getByText('Smtp').click();
const closed = await page.getByTestId('way-menu').waitFor({ state: 'hidden', timeout: 5000 }).then(() => true, () => false);
record('phone: a section from the menu closes it', closed && page.url().endsWith('#/section/Smtp'), page.url());
await context.close();
}

await browser.close();
const failed = results.filter((r) => !r.ok);
for (const r of results) console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
console.log(`\n${which} ${base}: ${results.length - failed.length}/${results.length} ok`);
process.exit(failed.length ? 1 : 0);
11 changes: 4 additions & 7 deletions src/ConfigWay.PostgreSQL.Tests/Integration/PostgresApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task InitializeAsync()
});

_app = builder.Build();
_app.UseConfigWay();
_app.UseConfigWay().AllowAnonymous();

await _app.StartAsync();
_client = _app.GetTestClient();
Expand Down Expand Up @@ -71,14 +71,11 @@ public async Task GetConfiguration_InitialValues_AreNull()
}

[Fact]
public async Task UpdateConfiguration_ValidSettings_Returns200WithEmptyErrors()
public async Task UpdateConfiguration_ValidSettings_Returns204()
{
var response = await PostUpdate(new[] { new { key = "Simple:Name", value = "Alice" } });

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var errors = await response.Content.ReadFromJsonAsync<string[]>();
Assert.NotNull(errors);
Assert.Empty(errors);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}

[Fact]
Expand Down Expand Up @@ -191,7 +188,7 @@ private async Task<WebApplication> CreateAppAsync()
});

var app = builder.Build();
app.UseConfigWay();
app.UseConfigWay().AllowAnonymous();
await app.StartAsync();
return app;
}
Expand Down
11 changes: 4 additions & 7 deletions src/ConfigWay.SqlServer.Tests/Integration/SqlServerApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public async Task InitializeAsync()
});

_app = builder.Build();
_app.UseConfigWay();
_app.UseConfigWay().AllowAnonymous();

await _app.StartAsync();
_client = _app.GetTestClient();
Expand Down Expand Up @@ -71,14 +71,11 @@ public async Task GetConfiguration_InitialValues_AreNull()
}

[Fact]
public async Task UpdateConfiguration_ValidSettings_Returns200WithEmptyErrors()
public async Task UpdateConfiguration_ValidSettings_Returns204()
{
var response = await PostUpdate(new[] { new { key = "Simple:Name", value = "Alice" } });

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var errors = await response.Content.ReadFromJsonAsync<string[]>();
Assert.NotNull(errors);
Assert.Empty(errors);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}

[Fact]
Expand Down Expand Up @@ -191,7 +188,7 @@ private async Task<WebApplication> CreateAppAsync()
});

var app = builder.Build();
app.UseConfigWay();
app.UseConfigWay().AllowAnonymous();
await app.StartAsync();
return app;
}
Expand Down
2 changes: 2 additions & 0 deletions src/ConfigWay.SqlServer/ConfigWay.SqlServer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<!-- Patched version over the one Microsoft.Data.SqlClient brings; see Directory.Packages.props. -->
<PackageReference Include="System.Security.Cryptography.Pkcs" />
</ItemGroup>

<ItemGroup>
Expand Down
45 changes: 32 additions & 13 deletions src/ConfigWay.Tests/Integration/ConfigurationApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public async Task InitializeAsync()
});

_app = builder.Build();
_app.UseConfigWay();
_app.UseConfigWay().AllowAnonymous();

await _app.StartAsync();
_client = _app.GetTestClient();
Expand Down Expand Up @@ -97,15 +97,37 @@ public async Task GetConfiguration_InitialValues_AreNull()
}

[Fact]
public async Task UpdateConfiguration_ValidSettings_Returns200WithEmptyErrors()
public async Task UpdateConfiguration_ValidSettings_Returns204()
{
var settings = new[] { new Setting("Simple:Name", "Alice") };
var response = await PostUpdateConfiguration(settings);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var errors = await response.Content.ReadFromJsonAsync<string[]>();
Assert.NotNull(errors);
Assert.Empty(errors);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}

[Fact]
public async Task UpdateConfiguration_InvalidValue_ReturnsValidationProblem()
{
var response = await PostUpdateConfiguration([new Setting("Typed:Count", "not a number")]);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
var problem = await response.Content.ReadFromJsonAsync<JsonElement>(JsonOpts);
var message = Assert.Single(problem.GetProperty("errors").GetProperty("settings").EnumerateArray());
Assert.StartsWith("Typed:Count", message.GetString());
}

[Fact]
public async Task UpdateConfiguration_InvalidValue_NothingSaved()
{
await PostUpdateConfiguration([new Setting("Simple:Name", "Alice"), new Setting("Typed:Count", "not a number")]);

var sections = await FetchSections();
var field = sections
.Single(s => s.Key == "Simple")
.Fields.Single(f => f.Key == "Name");

Assert.Null(field.Value);
}

[Fact]
Expand Down Expand Up @@ -385,16 +407,13 @@ await PostUpdateConfiguration([
}

[Fact]
public async Task UpdateConfiguration_DeleteReturns200WithEmptyErrors()
public async Task UpdateConfiguration_Delete_Returns204()
{
await PostUpdateConfiguration([new Setting("Arrays:Tags:0", "temp")]);

var response = await PostUpdateConfiguration([], keysToDelete: ["Arrays:Tags:0"]);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var errors = await response.Content.ReadFromJsonAsync<string[]>();
Assert.NotNull(errors);
Assert.Empty(errors);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}

private Task<HttpResponseMessage> PostGetConfiguration() =>
Expand Down Expand Up @@ -511,7 +530,7 @@ public async Task InitializeAsync()
});

_app = builder.Build();
_app.UseConfigWay();
_app.UseConfigWay().AllowAnonymous();

await _app.StartAsync();
_client = _app.GetTestClient();
Expand Down Expand Up @@ -685,7 +704,7 @@ public async Task InitializeAsync()
});

_app = builder.Build();
_app.UseConfigWay();
_app.UseConfigWay().AllowAnonymous();

await _app.StartAsync();
_client = _app.GetTestClient();
Expand Down
69 changes: 69 additions & 0 deletions src/ConfigWay.Tests/Integration/ShellTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Net;
using Kododo.ConfigWay.UI;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Xunit;

namespace Kododo.ConfigWay.Tests.Integration;

/// <summary>The editor runs in Way.Shell: the shell's page, the editor's module, and the shell's protection.</summary>
public class ShellTests
{
private static async Task<(WebApplication App, HttpClient Client)> StartAsync(Action<RouteGroupBuilder>? decide)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Production" });
builder.WebHost.UseTestServer();
builder.AddConfigWay(x =>
{
x.AddOptions<SimpleOptions>("Simple");
x.AddUiEditor();
});
var app = builder.Build();
var group = app.UseConfigWay("/settings");
decide?.Invoke(group);
await app.StartAsync();
return (app, app.GetTestClient());
}

[Fact]
public async Task Page_IsTheShellWithTheEditorsModule()
{
var (app, client) = await StartAsync(g => g.AllowAnonymous());
await using var _ = app;

var html = await client.GetStringAsync("/settings/");

Assert.Contains("""<base href="/settings/">""", html);
Assert.Contains("""<script type="importmap">""", html);
Assert.Contains("\"moduleUrl\":\"/settings/_way/way.js?v=", html);
Assert.Contains("\"apiBase\":\"/settings/api\"", html);
Assert.Contains("<title>Configuration</title>", html);
}

[Fact]
public async Task Module_IsServedFromTheEditorsAssembly()
{
var (app, client) = await StartAsync(g => g.AllowAnonymous());
await using var _ = app;

var module = await client.GetAsync("/settings/_way/way.js");

Assert.Equal(HttpStatusCode.OK, module.StatusCode);
Assert.Contains("contract:2", (await module.Content.ReadAsStringAsync()).Replace(" ", ""));
}

[Theory]
[InlineData("/settings/")]
[InlineData("/settings/_way/way.js")]
public async Task WithoutADecisionOnAuthorization_ItIsForbidden_OutsideDevelopment(string url)
{
var (app, client) = await StartAsync(decide: null);
await using var _ = app;

Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync(url)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync("/settings/api/GetConfiguration", JsonContent())).StatusCode);
}

private static StringContent JsonContent() => new("{}", System.Text.Encoding.UTF8, "application/json");
}
Loading
Loading