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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Changelog

## [Unreleased]

### 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
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
`adminConformanceChecks()` to run the checks yourself. Its own tests include
deliberately broken admins that it must reject. The stub admin is the
reference run.
- **`createAppHarness()`** (`@dune/testing/app-harness`): the in-memory harness
plus the full `createDuneApp()` app, with `request(path)` going through plugin
routes, the response-transform pipeline and the content catch-all.
- **`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.

## [1.0.3] — 2026-10-28

### Fixed
Expand Down
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,87 @@ Returns a `TestHarness` with:
| `rebuild()` | Trigger a full content rebuild and search index update |
| `dispose()` | Tear down the harness — safe to call multiple times |

### `createAppHarness(options?)`

`createTestHarness()` reaches only the read-only content API.
`createAppHarness()` also builds the app `dune serve` builds, so a plugin's
`mount()` routes, the response-transform pipeline and the inline-edit WebSocket
route are all reachable in process. Same options as `createTestHarness()`; the
result adds `request(path, init?)`, which goes through the whole app.

```ts
const h = await createAppHarness({ content, plugins: [myPlugin()] });
const res = await h.request("/about/");
```

Folder pages are served at their canonical path with a trailing slash
(`/about/`); `/about` answers 301.

### `createStubAdmin(options?)`

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
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
is not the built-in one, or to build a conformance test for an admin of your
own. `declareCookie: false` makes it a provider that does not say which cookie
carries its session.

```ts
const admin = createStubAdmin({ users: [{ username: "ed", password: "pw" }] });
const h = await createAppHarness({
content,
plugins: [admin.plugin, myPlugin()],
});
```

### `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:

- the admin provides `auth.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
have editing markers scrubbed; the editor gets `pages.update` and keeps the
markers;
- the cookie the admin declares in `sessionCookies` is the cookie its sign-in
sets (otherwise core would never consult it, and its pages could enter the
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`;
- 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`);
- `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.

```ts
runAdminConformance({
name: "my-admin",
async createApp({ content, extraPlugins, withAdmin }) {
/* build the app; register extraPlugins BEFORE your admin */
},
signInEditor: async (app) => "my_session=abc123", // the Cookie header value of an editor
});
```

The optional hooks (`signInViewer`, `editPage`, `createPage`, `deletePage`,
`adminPrefix`) enable the checks that need them.
`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 |
Expand Down
24 changes: 15 additions & 9 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@
"license": "MIT",
"exports": {
".": "./src/mod.ts",
"./harness": "./src/harness.ts"
"./harness": "./src/harness.ts",
"./app-harness": "./src/app-harness.ts",
"./stub-admin": "./src/stub-admin.ts",
"./conformance": "./src/conformance.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@^1",
"@std/yaml": "jsr:@std/yaml@^1",
"@dune/core": "jsr:@dune/core@^0.35",
"@dune/core/storage": "jsr:@dune/core@^0.35/storage",
"@dune/core/api": "jsr:@dune/core@^0.35/api",
"@dune/core/bootstrap": "jsr:@dune/core@^0.35/bootstrap",
"@dune/core/engine": "jsr:@dune/core@^0.35/engine",
"@dune/core/hooks": "jsr:@dune/core@^0.35/hooks",
"@dune/core/search": "jsr:@dune/core@^0.35/search",
"@dune/core/config": "jsr:@dune/core@^0.35/config"
"@dune/core": "jsr:@dune/core@^0.36",
"@dune/core/storage": "jsr:@dune/core@^0.36/storage",
"@dune/core/api": "jsr:@dune/core@^0.36/api",
"@dune/core/bootstrap": "jsr:@dune/core@^0.36/bootstrap",
"@dune/core/engine": "jsr:@dune/core@^0.36/engine",
"@dune/core/hooks": "jsr:@dune/core@^0.36/hooks",
"@dune/core/search": "jsr:@dune/core@^0.36/search",
"@dune/core/config": "jsr:@dune/core@^0.36/config",
"@dune/core/fresh-app": "jsr:@dune/core@^0.36/fresh-app",
"@dune/core/workflow": "jsr:@dune/core@^0.36/workflow",
"@dune/core/plugin-api": "jsr:@dune/core@^0.36/plugin-api"
},
"minimumDependencyAge": {
"age": "P1D",
Expand Down
64 changes: 64 additions & 0 deletions src/app-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* createAppHarness — the in-memory test harness plus the full Fresh app.
*
* `createTestHarness()` reaches only the read-only content API. This one also
* builds the app `dune serve` builds (`createDuneApp()`): content pages, plugin
* routes, the response-transform pipeline, the inline-edit WebSocket route,
* everything a plugin's `mount()` registers. Use it to test a plugin or an
* admin end to end, in process, without a server.
*
* ```ts
* import { createAppHarness } from "@dune/testing";
*
* const h = await createAppHarness({
* content: { "01.home/default.md": "---\ntitle: Home\n---\nHello" },
* plugins: [myPlugin()],
* });
* try {
* const res = await h.request("/");
* assertEquals(res.status, 200);
* } finally {
* await h.dispose();
* }
* ```
*
* @module
*/

import { createDuneApp } from "@dune/core/fresh-app";
import { createTestHarness } from "./harness.ts";
import type { TestHarness, TestHarnessOptions } from "./harness.ts";

/** A {@link TestHarness} that also serves the full Dune app. */
export interface AppHarness extends TestHarness {
/**
* Send an in-process HTTP request through the whole app (page cache,
* plugin routes, response transforms, content catch-all).
*/
request(path: string, init?: RequestInit): Promise<Response>;
}

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

/** Create an in-process Dune app over in-memory storage. */
export async function createAppHarness(
options: AppHarnessOptions = {},
): Promise<AppHarness> {
const h = await createTestHarness(options);
const { app } = await createDuneApp(h.bootstrap, {
root: "/dune-test-harness",
port: 0,
dev: false,
});
const handler = app.handler();
return {
...h,
request(path, init) {
return handler(new Request(new URL(path, "http://localhost:8000"), init));
},
};
}
Loading
Loading