Skip to content
Merged
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
48 changes: 48 additions & 0 deletions .github/workflows/ponymail-mcp-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: ponymail-mcp tests

on:
push:
branches: ["main", "rbowen-ponymail-mcp"]
paths:
- "mcp/ponymail-mcp/**"
- ".github/workflows/ponymail-mcp-tests.yml"
pull_request:
paths:
- "mcp/ponymail-mcp/**"
- ".github/workflows/ponymail-mcp-tests.yml"

permissions: {}

jobs:
test:
name: Test on Node ${{ matrix.node-version }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read

strategy:
fail-fast: false
matrix:
node-version: ["20", "22"]

defaults:
run:
working-directory: mcp/ponymail-mcp

steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false

- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: ${{ matrix.node-version }}

- name: Install dependencies
run: npm install --no-audit --no-fund

- name: Run tests
run: npm test
129 changes: 129 additions & 0 deletions mcp/ponymail-mcp/auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Tests for the session-persistence helpers in auth.js (loadSession,
// clearSession). The session file lives at ~/.ponymail-mcp/session.json,
// computed at module import time from os.homedir(), so each test spawns a
// child node process with HOME pointed at a temporary directory.
//
// The interactive performLogin() flow (browser open, local HTTP server,
// cookie-paste form, network validation) is not covered by these tests —
// it requires a real browser and network and belongs in an integration
// suite.

import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";

const here = path.dirname(fileURLToPath(import.meta.url));
const modulePath = path.join(here, "auth.js");

function withTempHome(fn) {
const home = mkdtempSync(path.join(tmpdir(), "ponymail-auth-test-"));
try {
return fn(home);
} finally {
rmSync(home, { recursive: true, force: true });
}
}

function runInChild(home, snippet) {
const code = `
const a = await import(${JSON.stringify(modulePath)});
const out = await (async () => { ${snippet} })();
process.stdout.write(JSON.stringify(out));
`;
const res = spawnSync("node", ["--input-type=module", "-e", code], {
env: { ...process.env, HOME: home, USERPROFILE: home },
encoding: "utf8",
});
if (res.status !== 0) {
throw new Error(`child failed: ${res.stderr}`);
}
return JSON.parse(res.stdout);
}

function writeSessionFile(home, payload) {
const dir = path.join(home, ".ponymail-mcp");
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, "session.json"), JSON.stringify(payload));
}

test("loadSession returns null when no session file exists", () => {
withTempHome((home) => {
const out = runInChild(home, `return a.loadSession();`);
assert.equal(out, null);
});
});

test("loadSession returns the cookie when the file is fresh", () => {
withTempHome((home) => {
writeSessionFile(home, {
cookie: "ponymail=abc123",
timestamp: Date.now(),
user: { fullname: "Test" },
});
const out = runInChild(home, `return a.loadSession();`);
assert.equal(out, "ponymail=abc123");
});
});

test("loadSession returns null when the session is older than 20 hours", () => {
withTempHome((home) => {
const TWENTY_ONE_HOURS = 21 * 60 * 60 * 1000;
writeSessionFile(home, {
cookie: "ponymail=stale",
timestamp: Date.now() - TWENTY_ONE_HOURS,
});
const out = runInChild(home, `return a.loadSession();`);
assert.equal(out, null);
});
});

test("loadSession returns the cookie when there is no timestamp at all", () => {
// Behaviour today: missing timestamp skips the expiry check.
withTempHome((home) => {
writeSessionFile(home, { cookie: "ponymail=untimestamped" });
const out = runInChild(home, `return a.loadSession();`);
assert.equal(out, "ponymail=untimestamped");
});
});

test("loadSession returns null when the file is malformed JSON", () => {
withTempHome((home) => {
const dir = path.join(home, ".ponymail-mcp");
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, "session.json"), "{ not json");
const out = runInChild(home, `return a.loadSession();`);
assert.equal(out, null);
});
});

test("loadSession returns null when the cookie field is missing", () => {
withTempHome((home) => {
writeSessionFile(home, { timestamp: Date.now() });
const out = runInChild(home, `return a.loadSession();`);
assert.equal(out, null);
});
});

test("clearSession removes an existing session file", () => {
withTempHome((home) => {
writeSessionFile(home, { cookie: "ponymail=x", timestamp: Date.now() });
const sessionFile = path.join(home, ".ponymail-mcp", "session.json");
assert.equal(existsSync(sessionFile), true, "precondition: file exists");

runInChild(home, `a.clearSession(); return null;`);

assert.equal(existsSync(sessionFile), false, "session file should be deleted");
});
});

test("clearSession is a no-op when no session file exists", () => {
withTempHome((home) => {
// Should not throw.
const out = runInChild(home, `a.clearSession(); return "ok";`);
assert.equal(out, "ok");
});
});
5 changes: 4 additions & 1 deletion mcp/ponymail-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "node --test restrictions.test.js"
"test": "node --test --test-reporter=spec restrictions.test.js auth.test.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1"
Expand Down
31 changes: 31 additions & 0 deletions mcp/ponymail-mcp/restrictions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ test("default restrictions allow ordinary lists", () => {
assert.equal(out.user, null);
});

test("default restrictions match case-insensitively against input", () => {
const out = runInChild({}, `
return {
upperList: r.restrictionFor("PRIVATE", "Kafka.Apache.ORG"),
mixedDomain: r.restrictionFor("Board", "APACHE.ORG"),
};
`);
assert.equal(out.upperList, "private@");
assert.equal(out.mixedDomain, "board@apache.org");
});

test("PONYMAIL_RESTRICTED_LISTS=none clears all pattern blocks", () => {
const out = runInChild({ PONYMAIL_RESTRICTED_LISTS: "none" }, `
return {
Expand Down Expand Up @@ -239,6 +250,26 @@ test("restrictionForAddress handles list@domain strings", () => {
assert.equal(out.nullArg, null);
});

test("isRestricted is a thin boolean wrapper around restrictionFor", () => {
const out = runInChild({}, `
return {
privateKafka: r.isRestricted("private", "kafka.apache.org"),
devKafka: r.isRestricted("dev", "kafka.apache.org"),
};
`);
assert.equal(out.privateKafka, true);
assert.equal(out.devKafka, false);
});

test("restrictionError mentions the address and the matched pattern", () => {
const out = runInChild({}, `
return r.restrictionError("private", "kafka.apache.org", "private@");
`);
assert.match(out, /private@kafka\.apache\.org/);
assert.match(out, /"private@"/);
assert.match(out, /PONYMAIL_RESTRICTED_LISTS|PONYMAIL_ALLOWED_LISTS/);
});

test("parsePatternList handles whitespace, blanks, and mixed case", () => {
const out = runInChild(
{ PONYMAIL_ALLOWED_LISTS: " Foo@, ,@BAR.org , baz@QUUX.com " },
Expand Down