Skip to content

Commit

Permalink
E2E tests (#98)
Browse files Browse the repository at this point in the history
  • Loading branch information
psrpinto authored Sep 22, 2023
1 parent b66f265 commit fdee886
Show file tree
Hide file tree
Showing 15 changed files with 950 additions and 9 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
vendor
readme.txt
release
/svn/
10 changes: 2 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,8 @@ bin/prepare-release.sh 1.2.3

A new draft PR will now have been created, and its branch checked out locally.

### Test the release
You can use [`wp-env`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/) to run a local WordPress instance with the plugin installed:

```shell
wp-env start
```

In your browser, navigate to the URL that `wp-env` wrote to the terminal and make sure that the plugin is working as expected.
### Run the tests
You must make sure tests pass before publishing a new release. See [End-to-end tests](tests/README.md) for instructions on running the tests.

### Add a Changelog
A Changelog must be added to the `Changelog` section of `README.md`. In the PR description, you can find a link to all the commits since the previous release. You should manually go through the list and identify merged PRs that should be included in the Changelog (i.e. PRs that result in user-facing changes).
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
}
},
"scripts": {
"phpcs": "phpcs --standard=phpcs.xml"
"phpcs": "phpcs --standard=phpcs.xml",
"test": "cd tests && yarn && yarn start"
},
"config": {
"sort-packages": true,
Expand Down
9 changes: 9 additions & 0 deletions tests/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ISSUER_URL="https://localhost:8443"
CLIENT_ID="oidc-server-plugin-tests"
CLIENT_SECRET="oidc-server-plugin-tests"
TLS_CA_CERT="../../matrix-oidc-playground/tls/ca/rootCA.pem"
TLS_CERT="../../matrix-oidc-playground/tls/tls.pem"
TLS_KEY="../../matrix-oidc-playground/tls/tls-key.pem"
APP_BASE_URL="https://localhost:7443"
WORDPRESS_USER="admin"
WORDPRESS_PASS="password"
3 changes: 3 additions & 0 deletions tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/.yarn
/node_modules
.env.local
1 change: 1 addition & 0 deletions tests/.yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
yarnPath: .yarn/releases/yarn-1.22.19.cjs
32 changes: 32 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# End-to-end tests

Running theses tests requires having [matrix-oidc-playground](https://github.com/Automattic/matrix-oidc-playground/) running in the same machine as the tests. Make sure to follow the setup instructions there before running the tests.

Once you have matrix-oidc-playground running, simply run:

```shell
composer test
```

The tests pass when the output ends with something like:


```shell
JWT token {
iss: 'https://localhost:8443/',
sub: 'admin',
aud: 'oidc-server-plugin-tests',
iat: 1695316090,
exp: 1695319690,
auth_time: 1695316090,
nonce: '7926217c4ad37e6db5cc8e6f78a421ed'
}
userinfo {
scope: 'openid profile',
username: 'admin',
name: 'admin',
nickname: 'admin',
picture: 'https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=96&d=mm&r=g',
sub: 'admin'
}
```
17 changes: 17 additions & 0 deletions tests/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
ISSUER_URL: string,
CLIENT_ID: string,
CLIENT_SECRET: string,
TLS_CA_CERT: string,
TLS_CERT: string,
TLS_KEY: string,
APP_BASE_URL: string,
WORDPRESS_USER: string,
WORDPRESS_PASS: string,
}
}
}

export {}
119 changes: 119 additions & 0 deletions tests/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import fs from "node:fs";
import path from "node:path";
import dotenv from "dotenv"
import {OpenIdClient} from "./src/OpenIdClient";
import {HttpsServer} from "./src/HttpsServer";
import {HttpsClient} from "./src/HttpsClient";
import crypto from "crypto";
import { parse as parseHtml } from 'node-html-parser';
import {AxiosResponse} from "axios";

dotenv.config({ path: ".env" });
if (fs.existsSync(".env.local")) {
dotenv.config({ path: ".env.local", override: true });
}

let httpsServer: HttpsServer;

async function run() {
const env = process.env;
if (!env.ISSUER_URL || !env.CLIENT_ID || !env.CLIENT_SECRET || !env.TLS_CA_CERT || !env.TLS_CERT || !env.TLS_KEY || !env.APP_BASE_URL || !env.WORDPRESS_USER || !env.WORDPRESS_PASS) {
console.error("Some or all required environment variables were not defined. Set them in the .env file.");
process.exit(1);
}

const caCert = fs.readFileSync(path.resolve(env.TLS_CA_CERT));

const openIdClient = new OpenIdClient({
issuerUrl: env.ISSUER_URL,
clientId: env.CLIENT_ID,
clientSecret: env.CLIENT_SECRET,
redirectUri: env.APP_BASE_URL,
caCert,
});

const httpsClient = new HttpsClient({
caCert,
})

httpsServer = new HttpsServer({
baseUrl: new URL(env.APP_BASE_URL),
tlsCert: fs.readFileSync(path.resolve(env.TLS_CERT)),
tlsKey: fs.readFileSync(path.resolve(env.TLS_KEY)),
});
httpsServer.start();

const state = crypto.randomBytes(16).toString("hex");
const nonce = crypto.randomBytes(16).toString("hex");

// Generate authorization URL.
const authorizationUrl = await openIdClient.authorizationUrl(state, nonce);

// Call authorization URL.
let response = await httpsClient.get(authorizationUrl);
let responseUrl = new URL(response.config.url ?? "");

// Get promise to next server request.
let serverRequest = httpsServer.once();

// Log in.
if (response.status === 200 && responseUrl.toString().includes("wp-login.php")) {
response = await httpsClient.post(new URL(`${env.ISSUER_URL}/wp-login.php`), {
testcookie: "1",
log: env.WORDPRESS_USER,
pwd: env.WORDPRESS_PASS,
redirect_to: responseUrl.searchParams.get("redirect_to"),
});
}

// Grant authorization.
await grantAuthorization(httpsClient, env.ISSUER_URL ?? "", response);

// Get access token.
const request = await serverRequest;
const tokenSet = await openIdClient.exchangeCodeForToken(request);
console.log("JWT token", parseJwt(tokenSet.id_token ?? ""));

// Get userinfo.
const userinfo = await openIdClient.userinfo(tokenSet.access_token ?? "");
console.debug("userinfo", userinfo);
}

async function grantAuthorization(httpsClient: HttpsClient, issuerUrl: string, response: AxiosResponse): Promise<AxiosResponse> {
const authorizeButtonMarkup = '<input type="submit" name="authorize" class="button button-primary button-large" value="Authorize"/>';
if (response.status !== 200 || !response.data.includes(authorizeButtonMarkup)) {
// Nothing to do, we were not shown the grant authorization screen.
return response;
}

const html = parseHtml(response.data);
let inputFields = html.querySelector("form")?.querySelectorAll("input");
if (!inputFields || inputFields.length === 0) {
throw "Authorization form not found";
}

inputFields = inputFields.filter(field => ["hidden", "submit"].includes(field.attrs.type));
const params = {};
// @ts-ignore
inputFields.forEach(field => params[field.attrs.name] = field.attrs.value);

return httpsClient.post(new URL(`${issuerUrl}/wp-json/openid-connect/authorize`), params);
}

function parseJwt(token: string) {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
}

void run().catch(error => {
console.error(error);
process.exit(1);
}).finally(() => {
if (httpsServer) {
void httpsServer.stop();
}
});
24 changes: 24 additions & 0 deletions tests/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"packageManager": "[email protected]",
"name": "openid-connect-server-integration-tests",
"version": "1.0.0",
"main": "index.ts",
"license": "MIT",
"type": "commonjs",
"scripts": {
"start": "ts-node-esm index.ts"
},
"devDependencies": {
"@types/node": "^20.6.2",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
},
"dependencies": {
"axios": "^1.5.0",
"dotenv": "^16.3.1",
"http-terminator": "^3.2.0",
"node-html-parser": "^6.1.10",
"openid-client": "^5.5.0",
"set-cookie-parser": "^2.6.0"
}
}
92 changes: 92 additions & 0 deletions tests/src/HttpsClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import https from "node:https";
import axios, {AxiosInstance, AxiosResponse} from "axios";
const setCookieParser = require('set-cookie-parser');

type Options = {
caCert: Buffer,
}

export class HttpsClient {
private readonly axios: AxiosInstance;
private cookies: string[] = [];

constructor(private readonly options: Options) {
this.axios = axios.create({
httpsAgent: new https.Agent({ ca: this.options.caCert }),
maxRedirects: 0,
validateStatus: function (status) {
return [200, 302].includes(status);
}
});

this.axios.interceptors.response.use(response => {
console.debug("response", response.status, response.config.url, "\n")
return response;
});

this.axios.interceptors.request.use(request => {
console.debug("request", request.url, request.data ?? "", "\n")
return request;
});
}

async get(url: URL): Promise<AxiosResponse> {
const response = await this.axios.get(url.toString(), {
headers: {
Cookie: this.cookieHeader(),
},
});

this.setCookies(response);

if (response.status === 302) {
return this.get(response.headers.location);
}

return response;
}

async post(url: URL, data: object): Promise<AxiosResponse> {
const formData = new FormData();
for (const property in data) {
// @ts-ignore
formData.append(property, data[property]);
}

const response = await this.axios.post(url.toString(), formData, {
headers: {
Cookie: this.cookieHeader(),
},
});

this.setCookies(response);

if (response.status === 302) {
return this.get(response.headers.location);
}

return response
}

private setCookies(response: AxiosResponse) {
const cookies = setCookieParser.parse(response);
for (const cookie of cookies) {
this.cookies[cookie.name] = cookie.value;
}
}

private cookieHeader(): string {
let header = "";
for (const name in this.cookies) {
const value = this.cookies[name];
if (value.trim() === "") {
continue;
}
if (header !== "") {
header += "; ";
}
header += `${encodeURIComponent(name)}=${encodeURIComponent(this.cookies[name])}`;
}
return header;
}
}
44 changes: 44 additions & 0 deletions tests/src/HttpsServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import http, {IncomingMessage, ServerResponse} from "node:http";
import https, {Server as BaseServer} from "node:https";
import {createHttpTerminator, HttpTerminator} from "http-terminator";

type Options = {
baseUrl: URL,
tlsCert: Buffer,
tlsKey: Buffer,
};

export class HttpsServer {
private readonly server: BaseServer<typeof http.IncomingMessage, typeof http.ServerResponse>;
private readonly terminator: HttpTerminator;

constructor(private readonly options: Options) {
this.server = https.createServer({
key: options.tlsKey,
cert: options.tlsCert,
});
this.terminator = createHttpTerminator({server: this.server});
}

async once(): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
this.server.once("error", error => reject(error));
this.server.once("request", (request, response) => {
response.end();
resolve(request);
});
});
}

start() {
// @ts-ignore
this.server.listen(this.options.baseUrl.port, this.options.baseUrl.hostname, () => {
console.info(`Server listening at ${this.options.baseUrl.toString()}`);
});
}

async stop() {
this.server.removeAllListeners();
void this.terminator.terminate();
}
}
Loading

0 comments on commit fdee886

Please sign in to comment.