feat: add server discovery tests and enhance public key validation

- Introduced a new test suite for server discovery functionality, ensuring proper registration and response handling.
- Enhanced public key validation logic to include detailed error messages for invalid keys.
- Updated package.json with a new test command for the discovery tests.
- Removed outdated Playwright CI workflow configuration.
This commit is contained in:
Nixyan 2026-03-10 14:05:04 -03:00
parent ea172050a6
commit 8309770be5
7 changed files with 87 additions and 74 deletions

View file

@ -1,28 +0,0 @@
name: Playwright Tests
on:
workflow_dispatch:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View file

@ -14,6 +14,9 @@ Your identity is `you@yourserver.com`. Your server, your data, your rules.
## Roadmap
- **Phase 1** — Core federation. Two servers can follow each other, post, and see each other's posts.
- - [X] — Two servers can follow each other, trust their keys and rotate them.
- - [ ] — One server can create posts, have users following each other and dms (unencrypted for now) works.
- - [ ] — Two servers can fetch posts, follows and other data from their users, including DMs.
- **Phase 2** — Server trust scoring and a public vouch ledger.
- **Phase 3** — Opt-in relay network for censorship resistance.
- **Phase 4** — End-to-end encryption via TBD.

View file

@ -17,6 +17,7 @@
"email:dev": "cross-env NODE_ENV=development email dev --dir src/lib/mail/templates --port 3001",
"test": "cross-env NODE_ENV=test playwright test",
"test:key": "cross-env NODE_ENV=test playwright test tests/key.test.ts",
"test:discover": "cross-env NODE_ENV=test playwright test tests/discover.test.ts",
"build": "next build",
"start": "cross-env NODE_ENV=production node src/server.ts",
"db:push": "drizzle-kit push",

View file

@ -2,75 +2,36 @@ import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
// Load .env.local so workers have DATABASE_URL, BETTER_AUTH_SECRET, etc.
dotenv.config({ path: path.resolve(__dirname, '.env.local') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Run your local dev server before starting the tests */
webServer: {
command: 'bun run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
});

View file

@ -33,14 +33,22 @@ async function upsertServer(url: string, publicKey: string) {
}).onConflictDoNothing();
}
const publicKeySchema = z.string().refine((key) => {
const publicKeySchema = z.string().superRefine((key, ctx) => {
let pub: forge.pki.rsa.PublicKey;
try {
const pub = forge.pki.publicKeyFromPem(key);
return pub.n.bitLength() >= 4096;
pub = forge.pki.publicKeyFromPem(key) as forge.pki.rsa.PublicKey;
} catch {
return false;
ctx.addIssue({ code: "custom", message: "Public key is not a valid PEM-encoded RSA key", input: key });
return;
}
}, { message: "Invalid public key" });
if (!pub.n) {
ctx.addIssue({ code: "custom", message: "Public key is not an RSA key", input: key });
return;
}
if (pub.n.bitLength() < 2048) {
ctx.addIssue({ code: "custom", message: `RSA key must be at least 2048 bits (got ${pub.n.bitLength()})`, input: key });
}
});
const schema = z.discriminatedUnion("method", [
z.object({
@ -93,6 +101,8 @@ async function registerServer(validated: Extract<z.infer<typeof schema>, { metho
return NextResponse.json({ error: "Invalid server" }, { status: 400 });
} else if (response.publicKey !== validated.publicKey) {
debug("REGISTER public key mismatch: provided vs fetched");
debug("REGISTER provided public key: %s", validated.publicKey);
debug("REGISTER fetched public key: %s", response.publicKey);
return NextResponse.json({ error: "Invalid public key" }, { status: 400 });
}

39
tests/discover.test.ts Normal file
View file

@ -0,0 +1,39 @@
import { expect, test } from "@playwright/test";
import createDebug from "debug";
import { clearServerRegistry, getServerByUrl, insertServerEcho, } from "./helpers/db";
const debug = createDebug("test:discover");
const url = "http://172.21.157.201:3001";
test.beforeEach(async () => {
await clearServerRegistry()
})
test.afterEach(async () => {
await clearServerRegistry()
})
test("discover server", async ({ request, page }) => {
const response = await request.post(`http://192.168.3.26:3000/discover`, {
data: {
method: "REGISTER",
url: new URL(url).toString(),
publicKey: process.env.FEDERATION_PUBLIC_KEY!,
}
})
const status = response.status()
const body = await response.json();
debug("response status: %o", status);
debug("response body: %o", body);
expect(status).toBe(200)
expect(body).toMatchObject({ message: "Server registered successfully" })
expect(body.echo).toBeInstanceOf(Object) // We can't assert the exact object because the echo is generated by the server
// Insert the server echo into our database
await insertServerEcho("http://192.168.3.26:3000", body.echo.publicKey as string);
// check on the database itself if the server was registered
const server = await getServerByUrl("http://192.168.3.26:3000");
expect(server).toBeDefined()
expect(server?.publicKey).toBe(body.echo.publicKey as string)
});

View file

@ -1,6 +1,7 @@
// tests/helpers/db.ts
import db from "@/lib/db";
import { rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import forge from "node-forge";
export function generateKeypair() {
@ -40,7 +41,33 @@ export async function seedChallenge(overrides?: Partial<typeof rotateChallengeTo
return row
}
export async function getServerByUrl(url: string) {
return (await db.select().from(serverRegistry).where(eq(serverRegistry.url, url)))[0]
}
export async function clearServerRegistry() {
return await db.delete(serverRegistry)
}
export async function clearRotateChallengeTokens() {
return await db.delete(rotateChallengeTokens)
}
export async function insertServerEcho(url: string, publicKey: string) {
await db.insert(serverRegistry).values({
id: crypto.randomUUID(),
url,
publicKey,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
isHealthy: true,
}).onConflictDoNothing()
}
export async function clearTables() {
await db.delete(rotateChallengeTokens)
await db.delete(serverRegistry)
return await Promise.all([
clearRotateChallengeTokens(),
clearServerRegistry(),
])
}