Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/aurora.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:
run: make build

acceptance:
name: "Aurora: Sign-in through Dex"
name: "Aurora: Sign-in through Dex and a magic link"
runs-on: ubuntu-latest
defaults:
run:
Expand Down
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ doing so:
- **Catalogues:** a package used by both workspaces may use `catalog:` only for
entries both catalogues define, and `workspace:*` only for packages both
workspaces have.
- **React Aria:** both harnesses override `react-aria-components`,
`react-aria`, `react-stately`, `@react-aria/utils`, `@react-spectrum/utils`
and `@internationalized/date` to the ranges in
`frontend/aurora/core/catalog.json`: `pnpm.overrides` in
`frontend/package.json`, `overrides` in `frontend/aurora/pnpm-workspace.yaml`.
Keep the two lists the same, and both lockfiles resolving the same versions,
so core's components run on one React Aria in both frontends. They go once
Volto's `@plone/components` and Aurora's agree by themselves.

The Aurora add-on renders Aurora's `/login` with its own page,
`aurora-identity/routes/login.tsx`. Aurora's registry cannot replace a route,
Expand Down Expand Up @@ -107,7 +115,7 @@ Run these before proposing a commit. CI runs the same ones.
| `make test` | root | `make backend-test` and `make frontend-test` |
| `make check-imports` | `backend/` | The core/server layer boundary |
| `make aurora-lint`, `make aurora-test`, `make aurora-build` | root | The Aurora add-on. Needs `make aurora-install`; CI runs them in `.github/workflows/aurora.yml` |
| `make acceptance-test` | `frontend/aurora` | A real sign-in through Dex, with Playwright. Needs the services its `acceptance-*` targets start; CI runs it too |
| `make acceptance-test` | `frontend/aurora` | A real sign-in through Dex and through a magic link, with Playwright. Needs the services its `acceptance-*` targets start; CI runs it too |
| `make docs-build` | root | Sphinx with `-W`, warnings as errors |
| `make vale` | `docs/` | Prose style. **Errors must be zero**; warnings are advisory |

Expand Down
1 change: 1 addition & 0 deletions backend/news/132.tests
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Installed the virtual host monster in the test layers' Zope root, as a real one has it. The Aurora add-on calls the backend through `VirtualHostBase` URLs, and its acceptance tests run against `ACCEPTANCE_TESTING`. @sneridagh
6 changes: 6 additions & 0 deletions backend/src/pas/plugins/identity/testing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from OFS.Application import AppInitializer
from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE
from plone.app.robotframework.testing import REMOTE_LIBRARY_BUNDLE_FIXTURE
from plone.app.testing import applyProfile
Expand All @@ -15,6 +16,11 @@ class Layer(PloneSandboxLayer):
defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,)

def setUpZope(self, app, configurationContext):
# The virtual host monster, which a real Zope root has and the test
# one does not. The Aurora add-on calls the backend through
# VirtualHostBase URLs, so its acceptance tests need it on the
# acceptance server; Volto's robot fixture installs it the same way.
AppInitializer(app).install_virtual_hosting()
# Load any other ZCML that is required for your tests.
# The z3c.autoinclude feature is disabled in the Plone fixture base
# layer.
Expand Down
11 changes: 9 additions & 2 deletions docs/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ to see the options instead.

### Signing in to Aurora end to end

The Aurora harness has Playwright tests that sign in through a real Dex.
The Aurora harness has Playwright tests that sign in through a real Dex and
through a magic link.
They need three services, each started in its own shell from `frontend/aurora`:

```shell
Expand All @@ -145,6 +146,12 @@ make acceptance-dex-start # Dex, with the backend's test configuration
make acceptance-frontend-start # a production build of Aurora, port 3000
```

The acceptance server is the add-on's own
`pas.plugins.identity.testing.ACCEPTANCE_TESTING` layer. Its
`collective.MockMailHost` keeps every message the site sends instead of
sending it, and the magic-link test reads the link back through the server's
Robot Framework remote library.

Then register Dex on the site and run the tests:

```shell
Expand All @@ -162,7 +169,7 @@ Run these before opening a pull request. CI runs the same ones.
| `make test` | `make backend-test` and `make frontend-test` |
| `make check-imports`, from `backend/` | That core never imports the `[server]` layer |
| `make aurora-lint`, `make aurora-test`, `make aurora-build` | The Aurora add-on, once `make aurora-install` has run |
| `make acceptance-test`, from `frontend/aurora` | A real sign-in to Aurora through Dex. See {ref}`contributing-aurora-acceptance` |
| `make acceptance-test`, from `frontend/aurora` | A real sign-in to Aurora, through Dex and through a magic link. See {ref}`contributing-aurora-acceptance` |
| `make docs-build` | The documentation, with warnings as errors |
| `make -C docs vale` | Prose style. **Errors must be zero**; warnings are advisory |

Expand Down
10 changes: 6 additions & 4 deletions frontend/aurora/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,18 @@ test: ## Run the add-on's unit tests
ci-test: ## Run the add-on's unit tests once
CI=1 pnpm test --run

# Acceptance: a real sign-in, through Dex, against the backend's acceptance
# server. Start the three services, each in its own shell, register the
# provider, then run the tests.
# Acceptance: a real sign-in, through Dex and through a magic link, against
# the backend's acceptance server. Start the three services, each in its own
# shell, register the provider, then run the tests.

ACCEPTANCE_API_PATH=http://localhost:55001/plone
DEX_CONFIG=$(CURRENT_DIR)/../../backend/tests/_resources/dex/config.yaml

# The add-on's own acceptance layer, not Volto's: it installs
# collective.MockMailHost, which keeps the magic links the tests read.
.PHONY: acceptance-backend-start
acceptance-backend-start: ## Start the backend's acceptance server on http://localhost:55001/plone
$(MAKE) -C ../../backend acceptance-backend-start
ZSERVER_HOST=0.0.0.0 ZSERVER_PORT=55001 ../../backend/.venv/bin/robot-server pas.plugins.identity.testing.ACCEPTANCE_TESTING

.PHONY: acceptance-dex-start
acceptance-dex-start: ## Start Dex with the backend's test configuration on http://127.0.0.1:5556/dex
Expand Down
106 changes: 106 additions & 0 deletions frontend/aurora/acceptance/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* What the acceptance tests ask of the backend directly.
*
* The backend's acceptance server, `pas.plugins.identity.testing.
* ACCEPTANCE_TESTING`: a site at `PLONE_API_PATH`, a Manager at
* `PLONE_ADMIN`, and `collective.MockMailHost`, which keeps every message
* the site sends instead of sending it. The server's Robot Framework remote
* library reads them back.
*/
const SITE = process.env.PLONE_API_PATH ?? 'http://localhost:55001/plone';
const ADMIN = process.env.PLONE_ADMIN ?? 'admin:secret';

const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Basic ${Buffer.from(ADMIN).toString('base64')}`,
};

/** The email "provider", which sends magic links. */
const EMAIL_PROVIDER = {
id: 'email',
driver: 'email',
title: 'Email',
enabled: true,
show_in_login: true,
config: {},
};

/** Stop offering magic links, so the other tests see Dex alone. */
export async function removeEmailProvider(): Promise<void> {
await fetch(`${SITE}/++api++/@identity-providers/${EMAIL_PROVIDER.id}`, {
method: 'DELETE',
headers,
});
}

/** Offer magic links on the login page. */
export async function addEmailProvider(): Promise<void> {
// One left behind by a run that stopped before its cleanup.
await removeEmailProvider();
const answer = await fetch(`${SITE}/++api++/@identity-providers`, {
method: 'POST',
headers,
body: JSON.stringify(EMAIL_PROVIDER),
});
if (!answer.ok) {
throw new Error(
`Adding the email provider failed: ${answer.status} ${await answer.text()}`,
);
}
}

/**
* The last message the site sent, as MockMailHost kept it.
*
* Robot Framework's remote protocol is XML-RPC, which answers with the
* message base64-encoded; inside it, the body is quoted-printable.
*
* @returns The message, decoded, or `''` when none was sent.
*/
async function lastSentEmail(): Promise<string> {
const answer = await fetch(`${SITE}/RobotRemote`, {
method: 'POST',
headers: { 'Content-Type': 'text/xml' },
body:
'<?xml version="1.0"?><methodCall><methodName>run_keyword</methodName>' +
'<params><param><value><string>get_the_last_sent_email</string>' +
'</value></param><param><value><array><data/></array></value></param>' +
'</params></methodCall>',
});
const xml = await answer.text();
const encoded = xml.match(
/<name>return<\/name>\s*<value><base64>([^<]*)<\/base64>/,
)?.[1];
if (!encoded) {
return '';
}
const message = Buffer.from(encoded, 'base64').toString('utf-8');
// Only the body is quoted-printable. Decoding the headers too would read
// the `?=` closing an encoded subject as a soft line break.
const split = message.search(/\r?\n\r?\n/);
const [head, body] =
split < 0 ? [message, ''] : [message.slice(0, split), message.slice(split)];
return (
head +
body
.replace(/=\r?\n/g, '')
.replace(/=([0-9A-F]{2})/g, (_, hex: string) =>
String.fromCharCode(parseInt(hex, 16)),
)
);
}

/**
* The magic link in the last message the site sent, if it went to `address`.
*
* @param address The address the link was asked for.
* @returns The link, or `null` when the last message is not one for it.
*/
export async function lastMagicLink(address: string): Promise<string | null> {
const message = await lastSentEmail();
if (!new RegExp(`^To: ${address}\\r?$`, 'm').test(message)) {
return null;
}
return message.match(/https?:\/\/\S*[?&]magic_link=[\w.-]+/)?.[0] ?? null;
}
50 changes: 50 additions & 0 deletions frontend/aurora/acceptance/tests/magic-link.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test';

import {
addEmailProvider,
lastMagicLink,
removeEmailProvider,
} from '../backend';

// With the email provider beside Dex there are two ways in, which would
// stop `/login` going straight to Dex in the other tests.
test.beforeAll(addEmailProvider);
test.afterAll(removeEmailProvider);

test.describe('Signing in to Aurora with a magic link', () => {
test('the link in the email signs the user in, once', async ({
page,
context,
}) => {
// An address nobody has used, so the only link sent to it is this run's.
const address = `magic-${Date.now()}@example.com`;

await page.goto('/login');
await page.getByRole('button', { name: 'Email' }).click();
await page.getByLabel('Email address').fill(address);
await page.getByRole('button', { name: 'Email me a link' }).click();
await expect(
page.getByText('If that address can sign in here'),
).toBeVisible();

const link = await lastMagicLink(address);
expect(link).not.toBeNull();
// The backend builds it from Aurora's address: the visitor comes back
// to Aurora, not to the backend.
const target = new URL(link!);
expect(target.origin).toBe(new URL(page.url()).origin);
expect(target.pathname).toBe('/login-identity');

await page.goto(link!);
await page.waitForURL((url) => url.pathname === '/');
const cookies = await context.cookies();
expect(cookies.map((cookie) => cookie.name)).toContain('auth_seven');

// A link works once.
await context.clearCookies();
await page.goto(link!);
await expect(page.getByRole('alert')).toContainText(
'That sign-in link is no longer valid.',
);
});
});
Loading
Loading