Commit 70128b40 authored by Grant's avatar Grant
Browse files

testkit: add test scenarios

Assisted-by: deepseek:v4-flash
parent 274043dd
Loading
Loading
Loading
Loading
+31 −161
Original line number Diff line number Diff line
@@ -5,12 +5,20 @@ import { randomUUID } from "node:crypto";
import { toTemporalInstant } from "@js-temporal/polyfill";
import { startActorServer } from "./helpers/actor-server.js";
import { getTestConfig } from "./helpers/config.js";
import {
  sendDmScenario,
  deleteScenario,
  sendTransientNoteScenario,
  replyScenario,
  handoffScenario,
  sidecarScenario,
} from "./scenarios/index.js";
import type { ScenarioContext } from "./scenarios/index.js";
import { type APub as TAPub } from "../apub/utils.js";

Date.prototype.toTemporalInstant = toTemporalInstant;

const DRY_SEND = !!process.env.TEST_DRY_SEND;

const MODE = process.env.FEDERATION_MODE === "docker" ? "docker" : "production";

let actorStop: (() => Promise<void>) | null = null;
@@ -19,7 +27,6 @@ let APub: TAPub;
let configStop: (() => Promise<void>) | null = null;

beforeAll(async () => {
  // Start our Fedify actor
  const { result, stop } = await startActorServer(MODE);
  actorStop = stop;
  APub = result.APub;
@@ -37,17 +44,16 @@ test("HANDOFF_TOKEN loaded", async () => {
});

describe("Federation tests", () => {
  // We'll run against all configured accounts in sequence
  const runTests = async () => {
    const { accounts, stop } = await getTestConfig();
    configStop = stop ?? null;

    for (const account of accounts) {
      describe(`Deliver to ${account.label}`, { concurrent: false }, () => {
        let testKit: FediTestKit;
        let sessionId: string;
        let sessionCode: string;
        let authSession: AuthSession;
        let testKit!: FediTestKit;
        let sessionId!: string;
        let sessionCode!: string;
        let authSession!: AuthSession;

        beforeAll(
          async () => {
@@ -73,160 +79,24 @@ describe("Federation tests", () => {
          if (!DRY_SEND) await testKit.cleanup();
        });

        test.sequential("APub#sendDM", async () => {
          await APub.send(authSession);
        });

        test.sequential("Delivery", { timeout: 30_000 }, async (ctx) => {
          if (DRY_SEND) return;
          const version = await testKit.getVersion();
          await ctx.annotate(
            `${account.label} reports as using ${account.type} ${version}`,
          );
          await testKit.expectToHave(sessionCode, 1000);
        });

        test.sequential("APub#delete", async () => {
          await APub.delete(authSession);
        });

        test.sequential("Delivery - Delete", { timeout: 30_000 }, async () => {
          if (DRY_SEND) return;
          await expect(
            testKit.expectToHave(sessionCode, 500),
          ).rejects.toThrow();
        });

        // ── TransientNote ──────────────────────────────────────────

        let transientNoteContent: string;

        test.sequential("APub#send with TransientNote", async () => {
          const actor = await APub.lookupActor(account.user);
          if (!actor)
            throw new Error(`Could not look up actor for ${account.user}`);

          const { TransientNote } = await import("../models/TransientNote.js");
          transientNoteContent = `Transient note test ${sessionId}`;
          const note = new TransientNote(actor, transientNoteContent);
          await APub.send(note);
        });

        test.sequential(
          "Delivery - TransientNote",
          { timeout: 30_000 },
          async () => {
            if (DRY_SEND) return;
            await testKit.expectToHave(transientNoteContent, 1000);
          },
        );

        // ── Reply ──────────────────────────────────────────────────

        let replyContent: string;

        test.sequential("APub#reply", { timeout: 60_000 }, async () => {
          if (DRY_SEND) return;

          let capturedObject: any = null;
          let capturedActor: any = null;
          let captureResolve: (value: void) => void;
          const captured = new Promise<void>((resolve) => {
            captureResolve = resolve;
          });

          global.TEST_FEDERATION_INCOMING_CREATE = (
            actor: any,
            object: any,
          ) => {
            capturedActor = actor;
            capturedObject = object;
            captureResolve!();
          };

          try {
            await testKit.send(
        const ctx: ScenarioContext = {
          testKit,
          APub,
          ourHandle,
              `Reply test original message ${sessionId}`,
            );

            await Promise.race([
              captured,
              new Promise((_, reject) =>
                setTimeout(
                  () =>
                    reject(new Error("Timed out waiting for incoming Create")),
                  30_000,
                ),
              ),
            ]);

            const { Note, Mention } = await import("@fedify/vocab");
            replyContent = `Reply to real message ${sessionId}`;
            const replyNote = new Note({
              to: capturedActor,
              content: replyContent,
              published: new Date().toTemporalInstant(),
              tags: [
                new Mention({
                  href: capturedActor.id!,
                  name: String(capturedActor.id!),
                }),
              ],
            });

            await APub.reply(capturedObject, replyNote);
          } finally {
            global.TEST_FEDERATION_INCOMING_CREATE = undefined;
          }
        });

        test.sequential("Delivery - Reply", { timeout: 30_000 }, async () => {
          if (DRY_SEND) return;
          await testKit.expectToHave(replyContent, 1000);
        });

        // ── Handoff flow ───────────────────────────────────────────

        test.sequential(
          "APub#send via HandoffSession",
          { timeout: 30_000 },
          async () => {
            if (DRY_SEND) return;
            const HANDOFF_TOKEN = process.env.HANDOFF_TOKEN!;
            await testKit.send(ourHandle, HANDOFF_TOKEN);
          },
        );

        test.sequential(
          "Delivery - HandoffSession",
          { timeout: 60_000 },
          async (ctx) => {
            if (DRY_SEND) return;
            await ctx.annotate("Expecting handoff URL in reply");
            await testKit.expectToHave("/handoff/", 3000);
          },
        );

        // ── Sidecar flow ───────────────────────────────────────────

        test.sequential("APub#send via SidecarSession", async () => {
          if (DRY_SEND) return;
          const { SidecarSession } =
            await import("../models/SidecarSession.js");
          const sidecar = await SidecarSession.create("internal");
          await testKit.send(ourHandle, `${sidecar.id} please authenticate me`);
        });
          sessionId,
          sessionCode,
          authSession,
          account,
          DRY_SEND,
        };

        test.sequential(
          "Delivery - SidecarSession",
          { timeout: 60_000 },
          async (ctx) => {
            if (DRY_SEND) return;
            await ctx.annotate("Expecting sidecar authenticated message");
            await testKit.expectToHave("Authenticated as", 3000);
          },
        );
        // Run scenarios in sequence — these are vitest.sequential calls
        sendDmScenario(ctx);
        deleteScenario(ctx);
        sendTransientNoteScenario(ctx);
        replyScenario(ctx);
        handoffScenario(ctx);
        sidecarScenario(ctx);
      });
    }
  };
+6 −10
Original line number Diff line number Diff line
import { type Express as TExpress } from "express";
import { Server } from "node:http";
import { openTunnel, Tunnel } from "@hongminhee/localtunnel";
import { createTunnel } from "@sc07/fedi-testkit";
import { randomUUID } from "node:crypto";
import type { APub } from "../../apub/utils.js";
import type { Federation } from "@fedify/fedify";
@@ -40,7 +40,7 @@ export async function startActorServer(mode: "docker" | "production"): Promise<{

  const PORT = Math.floor(Math.random() * (30_000 - 20_001)) + 20_001;
  let tunnelUrl: URL;
  let activeTunnel: Tunnel | null = null;
  let closeTunnel: (() => Promise<void>) | null = null;

  if (mode === "docker") {
    // Docker mode: Caddy proxies auth.ci.local → host.docker.internal:PORT
@@ -50,11 +50,9 @@ export async function startActorServer(mode: "docker" | "production"): Promise<{
    process.env.ACTOR_PORT = String(PORT);
  } else {
    // Production mode: start a public tunnel
    activeTunnel = await openTunnel({ port: PORT, service: "localhost.run" });
    tunnelUrl = new URL(activeTunnel.url);
    console.log(
      `[ActorServer] Tunnel localhost:${PORT} -> ${tunnelUrl} (pid: ${activeTunnel.pid})`,
    );
    const tunnel = await createTunnel(PORT);
    tunnelUrl = tunnel.url;
    closeTunnel = tunnel.close;
  }

  // Set environment before loading modules
@@ -99,9 +97,7 @@ export async function startActorServer(mode: "docker" | "production"): Promise<{
  };

  const stop = async () => {
    if (activeTunnel) {
      await activeTunnel.close();
    }
    await closeTunnel?.();
    await new Promise<void>((res) => {
      activeExpress.close(() => res());
    });
+154 −0
Original line number Diff line number Diff line
import { test, expect } from "vitest";
import type { FediTestKit, Software } from "@sc07/fedi-testkit";
import type { AuthSession } from "../../models/AuthSession.js";

export interface ScenarioContext {
  testKit: FediTestKit;
  APub: any;
  ourHandle: `${string}@${string}`;
  sessionId: string;
  sessionCode: string;
  authSession: AuthSession;
  account: Software & { label: string };
  DRY_SEND: boolean;
}

export function sendDmScenario(ctx: ScenarioContext): void {
  test.sequential("APub#sendDM", async () => {
    await ctx.APub.send(ctx.authSession);
  });

  test.sequential("Delivery", { timeout: 30_000 }, async (t) => {
    if (ctx.DRY_SEND) return;
    const version = await ctx.testKit.getVersion();
    await t.annotate(
      `${ctx.account.label} reports as using ${ctx.account.type} ${version}`,
    );
    await ctx.testKit.expectToHave(ctx.sessionCode, 1000);
  });
}

export function deleteScenario(ctx: ScenarioContext): void {
  test.sequential("APub#delete", async () => {
    await ctx.APub.delete(ctx.authSession);
  });

  test.sequential("Delivery - Delete", { timeout: 30_000 }, async () => {
    if (ctx.DRY_SEND) return;
    await expect(ctx.testKit.expectToHave(ctx.sessionCode, 500)).rejects
      .toThrow();
  });
}

export function sendTransientNoteScenario(ctx: ScenarioContext): void {
  let transientNoteContent: string;

  test.sequential("APub#send with TransientNote", async () => {
    const actor = await ctx.APub.lookupActor(ctx.account.user);
    if (!actor)
      throw new Error(`Could not look up actor for ${ctx.account.user}`);

    const { TransientNote } = await import("../../models/TransientNote.js");
    transientNoteContent = `Transient note test ${ctx.sessionId}`;
    const note = new TransientNote(actor, transientNoteContent);
    await ctx.APub.send(note);
  });

  test.sequential("Delivery - TransientNote", { timeout: 30_000 }, async () => {
    if (ctx.DRY_SEND) return;
    await ctx.testKit.expectToHave(transientNoteContent, 1000);
  });
}

export function replyScenario(ctx: ScenarioContext): void {
  let replyContent: string;

  test.sequential("APub#reply", { timeout: 60_000 }, async () => {
    if (ctx.DRY_SEND) return;

    let capturedObject: any = null;
    let capturedActor: any = null;
    let captureResolve: (value: void) => void;
    const captured = new Promise<void>((resolve) => {
      captureResolve = resolve;
    });

    global.TEST_FEDERATION_INCOMING_CREATE = (actor: any, object: any) => {
      capturedActor = actor;
      capturedObject = object;
      captureResolve!();
    };

    try {
      await ctx.testKit.send(
        ctx.ourHandle,
        `Reply test original message ${ctx.sessionId}`,
      );

      await Promise.race([
        captured,
        new Promise((_, reject) =>
          setTimeout(
            () => reject(new Error("Timed out waiting for incoming Create")),
            30_000,
          ),
        ),
      ]);

      const { Note, Mention } = await import("@fedify/vocab");
      replyContent = `Reply to real message ${ctx.sessionId}`;
      const replyNote = new Note({
        to: capturedActor,
        content: replyContent,
        published: new Date().toTemporalInstant(),
        tags: [
          new Mention({
            href: capturedActor.id!,
            name: String(capturedActor.id!),
          }),
        ],
      });

      await ctx.APub.reply(capturedObject, replyNote);
    } finally {
      global.TEST_FEDERATION_INCOMING_CREATE = undefined;
    }
  });

  test.sequential("Delivery - Reply", { timeout: 30_000 }, async () => {
    if (ctx.DRY_SEND) return;
    await ctx.testKit.expectToHave(replyContent, 1000);
  });
}

export function handoffScenario(ctx: ScenarioContext): void {
  test.sequential("APub#send via HandoffSession", { timeout: 30_000 }, async () => {
    if (ctx.DRY_SEND) return;
    const HANDOFF_TOKEN = process.env.HANDOFF_TOKEN!;
    await ctx.testKit.send(ctx.ourHandle, HANDOFF_TOKEN);
  });

  test.sequential("Delivery - HandoffSession", { timeout: 60_000 }, async (t) => {
    if (ctx.DRY_SEND) return;
    await t.annotate("Expecting handoff URL in reply");
    await ctx.testKit.expectToHave("/handoff/", 3000);
  });
}

export function sidecarScenario(ctx: ScenarioContext): void {
  test.sequential("APub#send via SidecarSession", async () => {
    if (ctx.DRY_SEND) return;
    const { SidecarSession } = await import("../../models/SidecarSession.js");
    const sidecar = await SidecarSession.create("internal");
    await ctx.testKit.send(
      ctx.ourHandle,
      `${sidecar.id} please authenticate me`,
    );
  });

  test.sequential("Delivery - SidecarSession", { timeout: 60_000 }, async (t) => {
    if (ctx.DRY_SEND) return;
    await t.annotate("Expecting sidecar authenticated message");
    await ctx.testKit.expectToHave("Authenticated as", 3000);
  });
}
 No newline at end of file
+1 −0
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@
    "script:setup-jwks": "tsx scripts/setup-jwks.ts"
  },
  "devDependencies": {
    "@sc07/fedi-testkit": "file:../fedi-testkit",
    "tsx": "^4.22.4"
  }
}
+14 −2
Original line number Diff line number Diff line
@@ -1831,12 +1831,23 @@ __metadata:

"@sc07/fedi-testkit@file:../../fedi-testkit::locator=%40fediverse-auth%2Fbackend%40workspace%3Abackend":
  version: 1.1.5
  resolution: "@sc07/fedi-testkit@file:../../fedi-testkit#../../fedi-testkit::hash=ec0c0e&locator=%40fediverse-auth%2Fbackend%40workspace%3Abackend"
  resolution: "@sc07/fedi-testkit@file:../../fedi-testkit#../../fedi-testkit::hash=af4c2b&locator=%40fediverse-auth%2Fbackend%40workspace%3Abackend"
  dependencies:
    execa: "npm:^9.6.1"
    puppeteer: "npm:^24.35.0"
    tsx: "npm:^4.21.0"
  checksum: 10c0/2567ac1ed85682dd2dd12b8778f65c814d27260ade89107137b4a4aabbd9ab0cf23cc1af630bf81f38fa6fd5af081eb2d4a67ac5dd9a40a55d1c70e76f5baf0a
  checksum: 10c0/beb813c3fd10ed67783d80f134172b0102a433b5e7834df1f7a9d9d6d50c8cd069e678adabd86067556f2c97de4926f427be36d2d45743df2231c5fd63061272
  languageName: node
  linkType: hard

"@sc07/fedi-testkit@file:../fedi-testkit::locator=fediverse-auth%40workspace%3A.":
  version: 1.1.5
  resolution: "@sc07/fedi-testkit@file:../fedi-testkit#../fedi-testkit::hash=af4c2b&locator=fediverse-auth%40workspace%3A."
  dependencies:
    execa: "npm:^9.6.1"
    puppeteer: "npm:^24.35.0"
    tsx: "npm:^4.21.0"
  checksum: 10c0/beb813c3fd10ed67783d80f134172b0102a433b5e7834df1f7a9d9d6d50c8cd069e678adabd86067556f2c97de4926f427be36d2d45743df2231c5fd63061272
  languageName: node
  linkType: hard

@@ -4113,6 +4124,7 @@ __metadata:
  version: 0.0.0-use.local
  resolution: "fediverse-auth@workspace:."
  dependencies:
    "@sc07/fedi-testkit": "file:../fedi-testkit"
    tsx: "npm:^4.22.4"
  languageName: unknown
  linkType: soft