Skip to content
Closed
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
26 changes: 21 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,28 @@

## [Unreleased]

Version 2.0.0 (the conformance driver changed incompatibly).

### Changed — BREAKING

- **The conformance driver has no `adminPrefix`.** The admin's URL prefix comes
from the `admin.prefix` capability slot, which an admin must now provide (core
no longer assumes `/admin`). `adminPrefix?: string` is replaced by
`hostsContributions?: boolean`, which only enables the `admin.pages` /
`admin.routes` checks. New checks: the admin provides `admin.prefix` (one
provider, a usable prefix), core reserves that prefix against plugin routes,
and with no admin `/admin` is an ordinary plugin path. The stub admin provides
`admin.prefix` (`/stub-admin` by default). Needs `@dune/core` 0.36 (dune#123).
- **`createTestHarness({ disableAdmin })` is gone.** Core no longer loads an
admin by itself, so there is nothing to disable; pass an admin in `plugins` to
test with one.

### Added

- **`runAdminConformance()`** (`@dune/testing/conformance`): the admin
conformance suite. A driver says how to build an app with an admin installed
and how to sign an editor in; the suite checks the contract with core (the
`auth.session` capability, session recognition and marker scrubbing, the
`editor.session` capability, session recognition and marker scrubbing, the
declared session cookie, mount-order independence, `onPageUpdate` /
`onPageCreate` / `onPageDelete` from the writer, `admin.pages` /
`admin.routes` mounting, and the site serving with no admin). Also
Expand All @@ -20,10 +36,10 @@
- **`createStubAdmin()`** (`@dune/testing/stub-admin`): a minimal replacement
admin built only on core's injected services, capabilities and mount API. It
proves Dune does not depend on `@dune/plugin-admin`: its tests cover login and
sessions, `auth.session` recognised by core's transform pipeline (and only its
own cookie), marker scrubbing, a page edit that fires `onPageUpdate`, a role
without edit rights, and a site with no admin at all. Needs `@dune/core` 0.36
(duneorg/dune#97); version bump and the `^0.36` pin are release work.
sessions, `editor.session` recognised by core's transform pipeline (and only
its own cookie), marker scrubbing, a page edit that fires `onPageUpdate`, a
role without edit rights, and a site with no admin at all. Needs `@dune/core`
0.36 (duneorg/dune#97); version bump and the `^0.36` pin are release work.

## [1.0.3] — 2026-10-28

Expand Down
36 changes: 20 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Folder pages are served at their canonical path with a trailing slash
A minimal replacement admin, built only on the services, capabilities and mount
API core hands every plugin (it imports nothing from `@dune/plugin-admin`). It
keeps users and sessions through `services.users` and `services.sessions`,
provides the `auth.session` and `scheduler.executor` capabilities, seeds authz
provides the `editor.session` and `scheduler.executor` capabilities, seeds authz
tuples through `services.authz`, and offers its own `POST /stub-admin/login`,
`GET /stub-admin/pages` and `POST /stub-admin/pages/edit` (which, as the writer,
fires `onPageUpdate`). Use it to check that your plugin works with an admin that
Expand All @@ -98,11 +98,11 @@ const h = await createAppHarness({
### `runAdminConformance(driver)`

What "meets the contract" means for an admin. Dune does not depend on
`@dune/plugin-admin`: any plugin that provides the `auth.session` capability is
the admin as far as core is concerned. Give the suite a driver (how to build an
app with your admin, how to sign an editor in) and it registers these tests:
`@dune/plugin-admin`: any plugin that provides the `editor.session` capability
is the admin as far as core is concerned. Give the suite a driver (how to build
an app with your admin, how to sign an editor in) and it registers these tests:

- the admin provides `auth.session` (exactly one provider, active);
- the admin provides `editor.session` (exactly one provider, active);
- the public site serves, and anonymous visitors are not admins;
- core's response pipeline recognises an editor session and **only** a real one:
no cookie, an unrelated cookie and a forged session cookie get no auth and
Expand All @@ -113,15 +113,19 @@ app with your admin, how to sign an editor in) and it registers these tests:
shared cache);
- an account without editing rights gets no editing chrome (`signInViewer`);
- a route in a plugin mounted before the admin can still tell who the admin is
through `auth.session`;
through `editor.session`;
- editing a page through the admin writes it and fires `onPageUpdate` exactly
once (`editPage`);
- creating a page fires `onPageCreate` exactly once and deleting one fires
`onPageDelete` exactly once, each with the path relative to the content
directory (`createPage`, `deletePage`);
- the admin provides `admin.prefix` (where it is mounted: one provider, a path
starting with `/` and with no trailing slash), and core reserves that prefix
against plugin routes;
- `admin.pages` and `admin.routes` contributed by another plugin are mounted
behind the login, the CSRF check and the permission (`adminPrefix`);
- with no admin at all the site serves and nothing admin-related exists.
behind the login, the CSRF check and the permission (`hostsContributions`);
- with no admin at all the site serves, nothing admin-related exists, and
`/admin` is an ordinary path for plugins.

```ts
runAdminConformance({
Expand All @@ -134,20 +138,20 @@ runAdminConformance({
```

The optional hooks (`signInViewer`, `editPage`, `createPage`, `deletePage`,
`adminPrefix`) enable the checks that need them.
`hostsContributions`) enable the checks that need them. The admin's prefix is
not a driver option: the suite reads it from the `admin.prefix` slot.
`adminConformanceChecks(driver)` returns the checks without registering them,
which is how the package tests that the suite rejects a broken admin. The stub
admin (`createStubAdmin()`) is the reference run.

### Options

| Option | Default | Description |
| -------------- | ------------------ | -------------------------------------------------------------------- |
| `content` | `{}` | Fixture content keyed by path relative to `content/` |
| `files` | `{}` | Extra files at arbitrary paths (config overrides, flex data, etc.) |
| `plugins` | `[]` | Plugin instances to register before bootstrap |
| `siteTitle` | `"Dune Test Site"` | Site title override |
| `disableAdmin` | `true` | Disable the admin plugin (heavy; skip unless testing admin behavior) |
| Option | Default | Description |
| ----------- | ------------------ | ------------------------------------------------------------------ |
| `content` | `{}` | Fixture content keyed by path relative to `content/` |
| `files` | `{}` | Extra files at arbitrary paths (config overrides, flex data, etc.) |
| `plugins` | `[]` | Plugin instances to register before bootstrap |
| `siteTitle` | `"Dune Test Site"` | Site title override |

## License

Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@dune/testing",
"version": "1.0.3",
"version": "2.0.0",
"license": "MIT",
"exports": {
".": "./src/mod.ts",
Expand Down
2 changes: 1 addition & 1 deletion src/app-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface AppHarness extends TestHarness {

/**
* Options for {@link createAppHarness}: those of {@link createTestHarness}, with
* the built-in admin disabled unless `disableAdmin: false`.
* no admin unless the test passes one in `plugins`.
*/
export type AppHarnessOptions = TestHarnessOptions;

Expand Down
130 changes: 110 additions & 20 deletions src/conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* core, without knowing anything else about it.
*
* Dune does not depend on `@dune/plugin-admin`: any plugin that provides the
* `auth.session` capability is "the admin" as far as core is concerned. This
* `editor.session` capability is "the admin" as far as core is concerned. This
* suite is what "meets the contract" means. Hand it a *driver* that says how to
* build an app with your admin installed and how to sign an editor in; it
* registers the tests.
Expand Down Expand Up @@ -57,7 +57,7 @@ export interface ConformanceAppOptions {
* is mount order, and the suite relies on the admin mounting after them.
*/
extraPlugins: DunePlugin[];
/** False: build the app with no admin at all (the built-in one switched off too). */
/** False: build the app with no admin at all. */
withAdmin: boolean;
}

Expand Down Expand Up @@ -104,10 +104,12 @@ export interface AdminConformanceDriver {
page: { sourcePath: string },
): Promise<void>;
/**
* Optional: the admin's URL prefix, if it mounts `admin.pages` and
* `admin.routes` contributions from other plugins. Enables those checks.
* Optional: true if the admin mounts `admin.pages` and `admin.routes`
* contributions from other plugins. Enables those checks. The admin's URL
* prefix is not part of the driver: the suite reads it from the
* `admin.prefix` slot the admin must provide.
*/
adminPrefix?: string;
hostsContributions?: boolean;
}

const CONTENT = {
Expand Down Expand Up @@ -166,6 +168,16 @@ function probe() {
return { plugin, seen };
}

/** Build an app just to read the prefix the admin provides. */
async function withPrefix(driver: AdminConformanceDriver): Promise<string> {
let prefix = "";
await withApp(driver, {}, (app) => {
prefix = adminPrefixOf(app);
return Promise.resolve();
});
return prefix;
}

async function withApp(
driver: AdminConformanceDriver,
options: Partial<ConformanceAppOptions>,
Expand All @@ -184,6 +196,13 @@ async function withApp(
}
}

/** The prefix the admin provides through `admin.prefix`; throws if it provides none. */
function adminPrefixOf(app: ConformanceApp): string {
const slot = app.bootstrap.capabilities.get("admin.prefix");
assertExists(slot, "no plugin provides admin.prefix");
return slot.prefix;
}

const cookieNames = (cookie: string) =>
cookie.split(";").map((c) => c.trim().split("=")[0]).filter(Boolean);

Expand All @@ -207,26 +226,68 @@ export function adminConformanceChecks(
checks.push({ name: `conformance [${driver.name}]: ${name}`, run });

t(
"provides auth.session, the capability that makes it the admin",
"provides editor.session, the capability that makes it the admin",
() =>
withApp(driver, {}, (app) => {
const providers = app.bootstrap.capabilities.providers();
const session = providers.find((p) => p.name === "auth.session");
assertExists(session, "no plugin provides auth.session");
const session = providers.find((p) => p.name === "editor.session");
assertExists(session, "no plugin provides editor.session");
assertEquals(
session.status,
"active",
"the auth.session factory did not produce a provider",
"the editor.session factory did not produce a provider",
);
assertEquals(
providers.filter((p) => p.name === "auth.session").length,
providers.filter((p) => p.name === "editor.session").length,
1,
"exactly one auth.session provider",
"exactly one editor.session provider",
);
return Promise.resolve();
}),
);

t(
"provides admin.prefix: a path prefix core can reserve, from exactly one provider",
() =>
withApp(driver, {}, (app) => {
const providers = app.bootstrap.capabilities.providers();
const slot = providers.filter((p) => p.name === "admin.prefix");
assertEquals(slot.length, 1, "exactly one admin.prefix provider");
assertEquals(slot[0].status, "active");
const prefix = adminPrefixOf(app);
assertEquals(
prefix.length > 1 && prefix.startsWith("/") && !prefix.endsWith("/"),
true,
`"${prefix}" must start with "/" and have no trailing slash`,
);
return Promise.resolve();
}),
);

t(
"core reserves the admin prefix: a plugin public route under it is refused",
async () => {
const prefix = await withPrefix(driver);
const squatter: DunePlugin = {
name: "conformance-squatter",
version: "1.0.0",
hooks: {},
publicRoutes: [{
path: `${prefix}/__conformance-squat`,
handler: () => new Response("squatted"),
}],
};
await withApp(driver, { extraPlugins: [squatter] }, async (app) => {
const res = await app.request(`${prefix}/__conformance-squat`);
assertEquals(
(await res.text()).includes("squatted"),
false,
"a plugin route answered inside the admin's prefix",
);
});
},
);

t(
"the public site serves, and anonymous visitors are not admins",
() =>
Expand All @@ -235,7 +296,7 @@ export function adminConformanceChecks(
assertEquals(home.status, 200);
assertStringIncludes(await home.text(), "<title>Home");

const auth = app.bootstrap.capabilities.get("auth.session")!;
const auth = app.bootstrap.capabilities.get("editor.session")!;
const anonymous = await auth.authenticate(
new Request("http://localhost:8000/"),
);
Expand Down Expand Up @@ -297,7 +358,7 @@ export function adminConformanceChecks(
"the cookie it declares is the cookie its sign-in sets",
() =>
withApp(driver, {}, async (app) => {
const declared = app.bootstrap.capabilities.get("auth.session")
const declared = app.bootstrap.capabilities.get("editor.session")
?.sessionCookies;
if (!declared) return; // undeclared is allowed: core then tries every request that has cookies
const cookie = await driver.signInEditor(app);
Expand Down Expand Up @@ -346,9 +407,10 @@ export function adminConformanceChecks(
hooks: {},
mount({ app, capabilities }) {
app.get("/__conformance/who", async (fc) => {
const result = await capabilities.get("auth.session")?.authenticate(
fc.req,
);
const result = await capabilities.get("editor.session")
?.authenticate(
fc.req,
);
return new Response(result?.authenticated ? "admin" : "anonymous");
});
},
Expand Down Expand Up @@ -476,9 +538,7 @@ export function adminConformanceChecks(
);
}

if (driver.adminPrefix) {
const prefix = driver.adminPrefix;

if (driver.hostsContributions) {
t(
"admin.pages from another plugin are mounted behind the admin's login",
async () => {
Expand All @@ -496,6 +556,7 @@ export function adminConformanceChecks(
},
};
await withApp(driver, { extraPlugins: [contributor] }, async (app) => {
const prefix = adminPrefixOf(app);
const cookie = await driver.signInEditor(app);
const anonymous = await app.request(`${prefix}/__conformance`);
assertEquals(
Expand Down Expand Up @@ -537,6 +598,7 @@ export function adminConformanceChecks(
},
};
await withApp(driver, { extraPlugins: [contributor] }, async (app) => {
const prefix = adminPrefixOf(app);
const cookie = await driver.signInEditor(app);
const url = `${prefix}/api/__conformance/act`;
const post = (headers: Record<string, string>) =>
Expand Down Expand Up @@ -573,12 +635,40 @@ export function adminConformanceChecks(
() =>
withApp(driver, { withAdmin: false }, async (app) => {
assertEquals((await app.request("/")).status, 200);
assertEquals(app.bootstrap.capabilities.get("auth.session"), undefined);
assertEquals(
app.bootstrap.capabilities.get("editor.session"),
undefined,
);
assertEquals(app.bootstrap.capabilities.get("admin.prefix"), undefined);
assertEquals(app.bootstrap.authz, undefined);
assertEquals((await app.request("/api/inline-edit/ws")).status, 501);
}),
);

t(
"without the admin nothing is reserved: /admin is an ordinary path for plugins",
() => {
const open: DunePlugin = {
name: "conformance-open",
version: "1.0.0",
hooks: {},
publicRoutes: [{
path: "/admin/__conformance-open",
handler: () => new Response("open"),
}],
};
return withApp(
driver,
{ withAdmin: false, extraPlugins: [open] },
async (app) => {
const res = await app.request("/admin/__conformance-open");
assertEquals(res.status, 200);
assertEquals(await res.text(), "open");
},
);
},
);

return checks;
}

Expand Down
Loading
Loading