diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index 8906648..0000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -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 diff --git a/README.md b/README.md index 9727b97..493489f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index 9d6311b..1f3a22d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index f220f7b..7100f36 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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' }, - // }, ], }); diff --git a/src/app/discover/route.ts b/src/app/discover/route.ts index 798eb9f..113b1dc 100644 --- a/src/app/discover/route.ts +++ b/src/app/discover/route.ts @@ -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, { 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 }); } diff --git a/tests/discover.test.ts b/tests/discover.test.ts new file mode 100644 index 0000000..685f86a --- /dev/null +++ b/tests/discover.test.ts @@ -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) +}); \ No newline at end of file diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index 4d7911f..aaebd2c 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -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