Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,49 @@ This command is interactive, so simply follow the prompts on your screen:

> **💡 Want to see a working example?** Check out the [example_plugin](./example_plugin) directory in this repository.

## Testing against more than one server

The block above describes a single local Paper server, which is all most projects need. When you also want to run the same suite against a staging server someone else keeps running, name the servers explicitly:

```kotlin
import me.drownek.plugwright.api.secret
import me.drownek.plugwright.external.ExternalMode
import me.drownek.plugwright.local.LocalMode

plugwright {
testsDir.set(file("src/test/e2e"))

environments {
create("local", LocalMode) {
minecraftVersion.set("1.21.11")
acceptEula.set(true)
}

create("staging", ExternalMode) {
host.set("mc.example.com")
minecraftVersion.set("1.20.4")

console { rcon { port.set(25575); password.set(secret.env("RCON_PASSWORD")) } }
accounts {
autoRegister {
usernamePattern.set("pw_%04d")
password.set(secret.env("BOT_PASSWORD"))
max.set(4)
}
}
plugins { npm("@plugwright/auth-authme") }
}
}
}
```

`./gradlew plugwrightTest` runs the matrix and prints a summary per environment; `./gradlew plugwrightTestStaging` runs one. A server behind a login wall needs a runner plugin to get past it, and `@plugwright/auth-authme` is the reference implementation for AuthMe-style login. Writing your own kind of environment — a proxy, a Compose stack — is a Kotlin mode plus an npm package.

- [Environments](https://plugwright.dev/environments) — modes, tasks, the matrix
- [External servers](https://plugwright.dev/external-servers) — console channels, account pools, cleanup
- [Runner plugins](https://plugwright.dev/plugins) — hooks, fixtures, matchers, inherited tests
- [Writing a mode](https://plugwright.dev/custom-modes)

## Why Plugwright vs MockBukkit?

| | **Plugwright** | **MockBukkit** |
Expand Down
77 changes: 77 additions & 0 deletions auth-authme-package/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# @plugwright/auth-authme

Reference [plugwright](https://github.com/Drownek/plugwright) authentication plugin for a server running AuthMe, or anything else that asks for a password in chat.

On every bot connection — the first bot of a test, a second bot from `createPlayer()`, every `player.rejoin()`, and the `external` mode's admin-bot console — it waits for the server's prompt and answers it. Registration is followed through to the login it triggers, because a command sent between the two is still rejected as unauthenticated.

Microsoft (online-mode) accounts are left alone; AuthMe never prompts them.

## Usage

```kotlin
environments {
create("staging", ExternalMode) {
accounts {
autoRegister {
usernamePattern.set("pw_%04d")
password.set(secret.env("BOT_PASSWORD"))
max.set(4)
}
}
plugins {
npm("@plugwright/auth-authme") {
options["loginCommand"] = "/log"
}
}
}
}
```

The same block works on a `LocalMode` environment. A local server running AuthMe puts up the same wall as a remote one.

## Which command it sends

The server decides, not the account. `account.justCreated` is a hint from the account pool, and it is wrong every time a pool account outlives the run that created it — that is the second run against any stand. So the plugin waits for either prompt and answers whichever arrived. The register pattern is tested first, since AuthMe's register prompt mentions the password too and would otherwise look like a login prompt.

## Options

| Option | Default | Meaning |
|---|---|---|
| `loginCommand` | `/login` | Sent with the password appended |
| `registerCommand` | `/register` | Sent with the password twice |
| `loginPromptPattern` | `log ?in\|password` | Regex identifying the login prompt |
| `registerPromptPattern` | `regist` | Regex identifying the register prompt |
| `successPattern` | `success\|welcome\|logged in\|authenticat` | Regex confirming the command was accepted |
| `authenticatedPattern` | `logged in\|authenticat` | Narrower regex confirming the player is actually authenticated |
| `timeoutMs` | `15000` | How long to wait for each prompt or confirmation |
| `password` | — | Fallback password for accounts that carry none |

All patterns are matched case-insensitively, and only against messages that arrived after the step they belong to. A greeting containing the word "welcome" would otherwise pass for a login confirmation, and the test would start before the player could run a single command.

`password` covers accounts an environment invents rather than leases: `LocalMode` hands every test a throwaway `Test_<uuid>` with no password of its own. Plugin options travel as plain strings, so use it only where the password protects nothing — a local server that is deleted after the run. Anywhere else, put the accounts in `accounts { }`, where the password stays a secret reference until the runner reads it.

## Preflight test

A `preflight` test ships with the plugin and runs before any user spec. The handshake above already throws on the first connection if it fails, so the test mostly exists to put a named failure at the top of the report instead of a stack trace buried in someone else's test.

## Server-side settings that matter

A stock AuthMe config is tuned for humans and rejects a test suite in three specific ways. On a disposable local server:

```yaml
settings:
registration:
dialog:
preJoin: { enable: false } # bots cannot answer a dialog
postJoin: { enable: false }
restrictions:
maxRegPerIp: 0 # every test registers from 127.0.0.1
Protection:
enableAntiBot: false # a test suite looks exactly like a bot attack
```

The `example_plugin` in this repository writes that file through `writeFiles { }` and runs its full suite against it.

## License

MIT
10 changes: 10 additions & 0 deletions auth-authme-package/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { test } from '@drownek/plugwright';

// If the login/register handshake in `onPlayerCreate` failed or timed out, `createPlayer()`
// would already have thrown before this test body ever runs — so reaching here at all is
// the actual assertion. The check below just makes that visible in the report.
test('authme login/register flow completes', async ({ player }) => {
if (!player.username) {
throw new Error('authme preflight: player has no username after join');
}
});
136 changes: 136 additions & 0 deletions auth-authme-package/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { definePlugin, poll } from '@drownek/plugwright';

const __dirname = dirname(fileURLToPath(import.meta.url));

export interface AuthAuthmeOptions {
/** Command sent for an existing account. */
loginCommand?: string;
/** Command sent for a freshly generated account (`account.justCreated`); receives the
* password twice, matching AuthMe's own `/register <pass> <pass>`. */
registerCommand?: string;
/** Regex (source only, case-insensitive) matched against server messages to detect the
* login prompt. */
loginPromptPattern?: string;
/** Regex matched against server messages to detect the register prompt. */
registerPromptPattern?: string;
/** Regex matched against server messages to confirm the command was accepted. */
successPattern?: string;
/** Regex matched against server messages to confirm the player is now authenticated.
* Narrower than [successPattern]: a registration is acknowledged before the login that
* follows it, and commands sent in between are still rejected. Deliberately excludes
* "success" and "welcome" — both fire on AuthMe's own "Successfully registered!" line,
* which would otherwise pass for the login that hasn't happened yet. Also avoids a bare
* "login" alternative: this pattern also gates the redundant-login fallback below, and a
* bare "login" would match AuthMe's login prompt ("Please, login with the command:
* /login <password>") too, turning a failed retry into a false "authenticated". */
authenticatedPattern?: string;
/** How long to wait for each prompt/confirmation before giving up. */
timeoutMs?: number;
/** Password used for accounts that carry none of their own — the throwaway identities an
* environment without an account pool generates per bot. Plugin options travel as plain
* values, so only use this where the password is worth nothing: a local, disposable
* server. Anywhere else, put the accounts in the pool and let the password be a secret. */
password?: string;
}

const DEFAULTS: Required<Omit<AuthAuthmeOptions, 'password'>> = {
loginCommand: '/login',
registerCommand: '/register',
loginPromptPattern: 'log ?in|password',
registerPromptPattern: 'regist',
successPattern: 'success|welcome|logged in|authenticat',
authenticatedPattern: 'success(ful)? login|logged in|authenticat',
timeoutMs: 15000,
};

// `onPlayerCreate` doesn't receive the plugin's options — only `setup()` does — so the
// resolved settings live here, captured once when the session starts. Safe because a runner
// process only ever runs one session at a time (see Session's own module-level caveats).
let resolved: Required<Omit<AuthAuthmeOptions, 'password'>> & { password?: string } = DEFAULTS;

/**
* Reference authentication plugin for a server running AuthMe (or anything with the same
* login/register-by-chat flow). `onPlayerCreate` fires on every bot connection — the initial
* join and every `player.rejoin()` — and on the `external` mode's admin-bot console too, since
* that connects through the exact same `PlayerWrapper.join()` path a test bot does.
*/
export default definePlugin<AuthAuthmeOptions>({
name: 'authme',
apiVersion: 1,
tests: [{ file: join(__dirname, 'auth.spec.js'), mode: 'preflight' }],

setup({ options }) {
resolved = { ...DEFAULTS, ...options };
},

async onPlayerCreate(player, { account }) {
// Online-mode (Microsoft) accounts never see AuthMe's offline-mode login wall.
if (account.auth === 'microsoft') return;

const password = account.password ?? resolved.password;
if (!password) {
throw new Error(
`authme: account "${account.username}" has no password to log in with. ` +
'Give the environment an accounts pool, or set the plugin\'s "password" option ' +
'for a throwaway local server.'
);
}

const registerPrompt = new RegExp(resolved.registerPromptPattern, 'i');
const loginPrompt = new RegExp(resolved.loginPromptPattern, 'i');
const successPattern = new RegExp(resolved.successPattern, 'i');

// Which of the two the server asks for is the server's decision, not ours:
// `account.justCreated` is a hint from the account pool, and it is wrong whenever a
// pool account outlives the run that created it. So wait for either prompt and answer
// the one that actually arrived. Register is tested first because AuthMe's register
// prompt names the password too, and would otherwise match the login pattern.
const joinIndex = player.getMessageBufferIndex();
const since = (index: number, pattern: RegExp): string | undefined =>
player.messageBuffer.slice(index).find((m: string) => pattern.test(m));

const isRegistration = await poll(
() => {
if (since(joinIndex, registerPrompt)) return true;
if (since(joinIndex, loginPrompt)) return false;
return undefined;
},
{
timeout: resolved.timeoutMs,
message: `authme: never saw a login or register prompt for "${account.username}"`,
},
);

// Everything below only looks at messages newer than the command. A server's greeting
// often carries a word like "welcome", which would otherwise pass for confirmation
// and let the test start before the player is actually authenticated.
const commandIndex = player.getMessageBufferIndex();
player.chat(isRegistration
? `${resolved.registerCommand} ${password} ${password}`
: `${resolved.loginCommand} ${password}`, { secrets: [password] });

await poll(() => since(commandIndex, successPattern), {
timeout: resolved.timeoutMs,
message: `authme: "${account.username}" did not confirm ${isRegistration ? 'registration' : 'login'} in time`,
});

if (!isRegistration) return;

// A registration is confirmed before the login it triggers, and a command sent in
// between is rejected as unauthenticated. AuthMe normally logs the player in itself;
// with forceLoginAfterRegister it does not, and the login has to be sent by hand.
const authenticated = new RegExp(resolved.authenticatedPattern, 'i');
const autoLoggedIn = await poll(() => since(commandIndex, authenticated), { timeout: 3000 })
.catch(() => null);
if (autoLoggedIn) return;

const loginIndex = player.getMessageBufferIndex();
player.chat(`${resolved.loginCommand} ${password}`, { secrets: [password] });
await poll(() => since(loginIndex, authenticated), {
timeout: resolved.timeoutMs,
message: `authme: "${account.username}" registered but never logged in`,
});
},
});
Loading