Commit 274043dd authored by Grant's avatar Grant
Browse files

testkit: implement base testkit rewrite

Assisted-by: deepseek:v4-flash
parent 778b393c
Loading
Loading
Loading
Loading
+3 −2
Original line number Diff line number Diff line
@@ -14,7 +14,7 @@
    "@js-temporal/polyfill": "^0.5.1",
    "@logtape/logtape": "^0.9.2",
    "@prisma/client": "^5.22.0",
    "@sc07/fedi-testkit": "^1.1.5",
    "@sc07/fedi-testkit": "file:../../fedi-testkit",
    "@tsconfig/recommended": "^1.0.13",
    "body-parser": "^1.20.5",
    "cookie-parser": "^1.4.7",
@@ -49,6 +49,7 @@
    "test:types": "tsc --noEmit",
    "start": "node dist/index.js",
    "test": "vitest",
    "ci:test": "vitest --reporter=default --reporter=junit --outputFile=./vitest.xml"
    "ci:test": "vitest --reporter=default --reporter=junit --outputFile=./vitest.xml",
    "ci:test:federation": "vitest run src/__tests__/federation.test.ts --reporter=default --reporter=junit --outputFile=./vitest.xml"
  }
}
+210 −387
Original line number Diff line number Diff line
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { type Express as TExpress } from "express";
import {
  FediTestKit,
  type Software as FTK_Software,
  SOFTWARES,
} from "@sc07/fedi-testkit";
import { openTunnel, Tunnel } from "@hongminhee/localtunnel";
import { Server } from "node:http";
import type { IAPubUtils, IAPubUtils_Static } from "../apub/utils.js";
import { FediTestKit } from "@sc07/fedi-testkit";
import type { AuthSession } from "../models/AuthSession.js";
import type { Federation } from "@fedify/fedify";
import { randomUUID } from "node:crypto";
import { configure, getConsoleSink } from "@logtape/logtape";
import { toTemporalInstant } from "@js-temporal/polyfill";
import { startActorServer } from "./helpers/actor-server.js";
import { getTestConfig } from "./helpers/config.js";
import { type APub as TAPub } from "../apub/utils.js";

Date.prototype.toTemporalInstant = toTemporalInstant;

/**
 * Send ap object without initiating testkit
 */
const DRY_SEND = !!process.env.TEST_DRY_SEND;

interface ITestAccount extends FTK_Software {}
const MODE = process.env.FEDERATION_MODE === "docker" ? "docker" : "production";

const getTestConfig = async (): Promise<{
  accounts: (ITestAccount & { label: string })[];
}> => {
  const mode = process.env.TEST_FEDERATION_MODE;

  if (mode === "KEYRING") {
    const host = process.env.TEST_FEDERATION_KEYRING_HOST;
    const auth = process.env.TEST_FEDERATION_KEYRING_AUTH;
    const service = process.env.TEST_FEDERATION_KEYRING_SERVICE;

    const req = await fetch(new URL(`/service/${service}`, host), {
      headers: {
        authorization: `Bearer ${auth}`,
      },
    });

    if (!req.ok) throw new Error("[Keyring] Failed to get service config");

    const data = await req.json();
    console.log("[Keyring] Loaded service from keyring");

    return {
      accounts: [
        {
          label: service,
          ...(data as any),
        },
      ],
    };
  }

  const input = process.env.TEST_FEDERATION_ACCOUNTS;
  if (!input) throw new Error("TEST_FEDERATION_ACCOUNTS is not set");

  const parsing = JSON.parse(input);
  if (
    Array.isArray(parsing) &&
    parsing.every(
      (entry): entry is ITestAccount & { label: string } =>
        typeof entry === "object" &&
        //
        "type" in entry &&
        typeof entry.type === "string" &&
        SOFTWARES.indexOf(entry.type) > -1 &&
        //
        "host" in entry &&
        typeof entry.host === "string" &&
        entry.host.startsWith("https://") &&
        //
        "user" in entry &&
        typeof entry.user === "string" &&
        entry.user.indexOf("@") > 1 &&
        entry.user.indexOf("@") === entry.user.lastIndexOf("@") &&
        //
        "auth" in entry &&
        typeof entry.auth === "string" &&
        //
        "label" in entry &&
        typeof entry.label === "string",
    )
  ) {
    return {
      accounts: parsing,
    };
  }

  throw new Error("TEST_FEDERATION_ACCOUNTS is not valid");
};

let Express: TExpress;
let APub: (new (...args: any) => IAPubUtils) & IAPubUtils_Static;
let federation: Federation<any>;
let USER_IDENTIFIER: string;
let HANDOFF_TOKEN: string;

/**
 * Load modules we are testing
 * These modules may use process.env immediately
 */
const load = async () => {
  const lib_express = await import("../routes/express.js");
  const lib_apub_utils = await import("../apub/utils.js");
  const lib_apub_federation = await import("../apub/federation.js");

  Express = lib_express.app;
  APub = lib_apub_utils.APub;
  federation = lib_apub_federation.federation;
  USER_IDENTIFIER = lib_apub_federation.USER_IDENTIFIER;
};

const PORT = Math.floor(Math.random() * (30_000 - 20_001)) + 20_001;
let ActiveTunnel: Tunnel;
let ActiveExpress: Server;
let ActiveFederation: AbortController;

let TestConfig = await getTestConfig();
let actorStop: (() => Promise<void>) | null = null;
let ourHandle: `${string}@${string}`;
let APub: TAPub;
let configStop: (() => Promise<void>) | null = null;

beforeAll(async () => {
  await configure({
    sinks: { console: getConsoleSink() },
    loggers: [
      {
        category: "fedify",
        sinks: ["console"],
        lowestLevel: "debug",
      },
    ],
    filters: {},
  });

  ActiveTunnel = await openTunnel({ port: PORT, service: "localhost.run" });
  console.log(
    `[Prepare] Tunnel localhost:${PORT} -> ${ActiveTunnel.url} (pid: ${ActiveTunnel.pid})`,
  );

  // modify environment

  process.env.PORT = PORT + "";
  process.env.OIDC_ISSUER = ActiveTunnel.url.toString();

  // Generate and set a handoff token before modules are loaded,
  // since Handoff.HANDOFF_TOKEN captures the env value at import time.
  HANDOFF_TOKEN = "test-handoff-" + randomUUID();
  process.env.HANDOFF_TOKEN = HANDOFF_TOKEN;

  // load modules
  await load();

  // derive our actor handle after OIDC_ISSUER is stable
  ourHandle = USER_IDENTIFIER + "@" + new URL(process.env.OIDC_ISSUER).host;

  ActiveFederation = new AbortController();
  process.once("SIGINT", () => ActiveFederation?.abort());
  void federation.startQueue(undefined, {
    signal: ActiveFederation.signal,
  });

  await new Promise<void>((res) => {
    ActiveExpress = Express.listen(PORT, () => res());
  });

  console.log(`[Prepare] Express running on localhost:${PORT}`);
  // Start our Fedify actor
  const { result, stop } = await startActorServer(MODE);
  actorStop = stop;
  APub = result.APub;
  ourHandle = result.handle as `${string}@${string}`;
});

afterAll(async () => {
  await ActiveTunnel.close();
  await new Promise<void>((res) => {
    ActiveExpress.close(() => res());
  });
  ActiveFederation.abort();
  await actorStop?.();
  await configStop?.();
});

test("HANDOFF_TOKEN loaded", async () => {
  const { Handoff } = await import("../handoff/index.js");

  expect(Handoff.HANDOFF_TOKEN).toBe(HANDOFF_TOKEN);
  expect(Handoff.HANDOFF_TOKEN).toBe(process.env.HANDOFF_TOKEN);
});

describe.for(
  TestConfig.accounts.map(({ label, ...account }) => ({ label, account })),
)("Deliver to $label", { concurrent: true }, async ({ label, account }) => {
  let TestKit: FediTestKit;
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;
@@ -194,11 +53,10 @@ describe.for(
          async () => {
            const { AuthSession } = await import("../models/AuthSession.js");

      if (!DRY_SEND) TestKit = await FediTestKit.create(account);
      sessionId = "test-" + label + "-" + randomUUID();
            testKit = await FediTestKit.create(account);
            sessionId = "test-" + account.label + "-" + randomUUID();
            sessionCode = AuthSession.generateCode();

      // @ts-ignore This has a private constructor
            // @ts-ignore Private constructor
            authSession = new AuthSession({
              id: sessionId,
              one_time_code: sessionCode,
@@ -206,55 +64,47 @@ describe.for(
              createdAt: new Date(),
              expiresAt: new Date(),
            });

      if (!DRY_SEND) await TestKit.setup();
            await testKit.setup();
          },
          account.type === "MBIN" ? 60000 : 10000,
        );

  // ── Auth session (existing) ────────────────────────────────────────
        afterAll(async () => {
          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) {
      await new Promise((res) => {});
    } else {
      const version = await TestKit.getVersion();
          if (DRY_SEND) return;
          const version = await testKit.getVersion();
          await ctx.annotate(
        `${label} reports as using version ${account.type} ${version}`,
            `${account.label} reports as using ${account.type} ${version}`,
          );
      await TestKit.expectToHave(sessionCode, 1000);
    }
          await testKit.expectToHave(sessionCode, 1000);
        });

  // ── APub#delete ────────────────────────────────────────────────────

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

  test.sequential("Delivery - Delete", { timeout: 30_000 }, async (ctx) => {
    if (DRY_SEND) {
      await new Promise((res) => {});
    } else {
      // After deletion the message should no longer appear in the inbox
      await expect(TestKit.expectToHave(sessionCode, 500)).rejects.toThrow();
      await ctx.annotate(
        `Confirmed deletion: "${sessionCode}" no longer visible`,
      );
    }
        test.sequential("Delivery - Delete", { timeout: 30_000 }, async () => {
          if (DRY_SEND) return;
          await expect(
            testKit.expectToHave(sessionCode, 500),
          ).rejects.toThrow();
        });

  // ── APub#send with TransientNote ───────────────────────────────────
        // ── 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}`);
          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}`;
@@ -265,24 +115,19 @@ describe.for(
        test.sequential(
          "Delivery - TransientNote",
          { timeout: 30_000 },
    async (ctx) => {
      if (DRY_SEND) {
        await new Promise((res) => {});
      } else {
        await ctx.annotate(`Expecting transient note content`);
        await TestKit.expectToHave(transientNoteContent, 1000);
      }
          async () => {
            if (DRY_SEND) return;
            await testKit.expectToHave(transientNoteContent, 1000);
          },
        );

  // ── APub#reply ─────────────────────────────────────────────────────
  // Uses the TEST_FEDERATION_INCOMING_CREATE global hook to capture the
  // actual Object received via ActivityPub, then replies to it.
        // ── Reply ──────────────────────────────────────────────────

        let replyContent: string;

        test.sequential("APub#reply", { timeout: 60_000 }, async () => {
    // Set up the capture hook before the remote sends us a DM
          if (DRY_SEND) return;

          let capturedObject: any = null;
          let capturedActor: any = null;
          let captureResolve: (value: void) => void;
@@ -290,31 +135,32 @@ describe.for(
            captureResolve = resolve;
          });

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

          try {
      // Have the remote send us a DM — our Fedify inbox listener
      // deserializes it and fires the Create handler, which invokes
      // the capture hook above.
      await TestKit.send(ourHandle, `Reply test original message ${sessionId}`);
            await testKit.send(
              ourHandle,
              `Reply test original message ${sessionId}`,
            );

      // Wait for Fedify's queue to process the incoming activity
      const timeout = 30_000;
            await Promise.race([
              captured,
              new Promise((_, reject) =>
                setTimeout(
            () => reject(new Error("Timed out waiting for incoming Create")),
            timeout,
                  () =>
                    reject(new Error("Timed out waiting for incoming Create")),
                  30_000,
                ),
              ),
            ]);

      // Reply using the real received object
            const { Note, Mention } = await import("@fedify/vocab");
            replyContent = `Reply to real message ${sessionId}`;
            const replyNote = new Note({
@@ -331,82 +177,59 @@ describe.for(

            await APub.reply(capturedObject, replyNote);
          } finally {
      // Clean up the hook
            global.TEST_FEDERATION_INCOMING_CREATE = undefined;
          }
        });

  test.sequential("Delivery - Reply", { timeout: 30_000 }, async (ctx) => {
    if (DRY_SEND) {
      await new Promise((res) => {});
    } else {
      await ctx.annotate(`Expecting reply content`);
      await TestKit.expectToHave(replyContent, 1000);
    }
        test.sequential("Delivery - Reply", { timeout: 30_000 }, async () => {
          if (DRY_SEND) return;
          await testKit.expectToHave(replyContent, 1000);
        });

  // ── APub#send via HandoffSession (handoff flow) ────────────────────
        // ── Handoff flow ───────────────────────────────────────────

        test.sequential(
          "APub#send via HandoffSession",
          { timeout: 30_000 },
          async () => {
            if (DRY_SEND) return;

      // The remote sends us a DM containing the handoff token.
      // Our inbox handler (HandoffActivityPub.handle) processes it,
      // creates a HandoffSession, and replies via APub.reply()
      // with a URL containing "/handoff/".
      await TestKit.send(ourHandle, HANDOFF_TOKEN);
            const HANDOFF_TOKEN = process.env.HANDOFF_TOKEN!;
            await testKit.send(ourHandle, HANDOFF_TOKEN);
          },
        );

        test.sequential(
    "Delivery - HandoffSession response",
          "Delivery - HandoffSession",
          { timeout: 60_000 },
          async (ctx) => {
      if (DRY_SEND) {
        await new Promise((res) => {});
      } else {
        await ctx.annotate(`Expecting handoff URL in reply`);
        // The reply from the handoff handler contains the handoff page URL
        await TestKit.expectToHave("/handoff/", 3000);
      }
            if (DRY_SEND) return;
            await ctx.annotate("Expecting handoff URL in reply");
            await testKit.expectToHave("/handoff/", 3000);
          },
        );

  // ── APub#send via SidecarSession (sidecar flow) ────────────────────

  let sidecarId: string;
        // ── Sidecar flow ───────────────────────────────────────────

        test.sequential("APub#send via SidecarSession", async () => {
          if (DRY_SEND) return;

    const { SidecarSession } = await import("../models/SidecarSession.js");
          const { SidecarSession } =
            await import("../models/SidecarSession.js");
          const sidecar = await SidecarSession.create("internal");
    sidecarId = sidecar.id;

    // The remote sends us a DM containing the sidecar session ID.
    // Our inbox handler (HandoffSidecar.handle) processes it,
    // claims the session, and sends a TransientNote via APub.send()
    // with text that includes "Authenticated as".
    await TestKit.send(ourHandle, `${sidecar.id} please authenticate me`);
          await testKit.send(ourHandle, `${sidecar.id} please authenticate me`);
        });

        test.sequential(
    "Delivery - SidecarSession response",
          "Delivery - SidecarSession",
          { timeout: 60_000 },
          async (ctx) => {
      if (DRY_SEND) {
        await new Promise((res) => {});
      } else {
        await ctx.annotate(`Expecting sidecar authenticated message`);
        await TestKit.expectToHave("Authenticated as", 3000);
      }
            if (DRY_SEND) return;
            await ctx.annotate("Expecting sidecar authenticated message");
            await testKit.expectToHave("Authenticated as", 3000);
          },
        );

  afterAll(async () => {
    if (!DRY_SEND) await TestKit.cleanup();
      });
    }
  };

  runTests();
});
+113 −0
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 { randomUUID } from "node:crypto";
import type { APub } from "../../apub/utils.js";
import type { Federation } from "@fedify/fedify";
import { configure, getConsoleSink } from "@logtape/logtape";

export interface ActorServerResult {
  url: URL;
  handle: string;
  federation: Federation<any>;
  APub: APub;
  USER_IDENTIFIER: string;
}

/**
 * Boots the Express + Fedify actor behind either a Caddy reverse proxy
 * (Docker mode) or a public tunnel (production mode).
 */
export async function startActorServer(mode: "docker" | "production"): Promise<{
  result: ActorServerResult;
  stop: () => Promise<void>;
}> {
  await configure({
    sinks: { console: getConsoleSink() },
    loggers: [
      {
        category: "fedify",
        sinks: ["console"],
        lowestLevel: "debug",
      },
      {
        category: ["logtape", "meta"],
        lowestLevel: "warning",
      },
    ],
    filters: {},
  });

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

  if (mode === "docker") {
    // Docker mode: Caddy proxies auth.ci.local → host.docker.internal:PORT
    // We need to tell DockerInstanceManager which port we're on.
    // The Caddyfile has a placeholder — we pass it via env to compose.
    tunnelUrl = new URL("https://auth.ci.local");
    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})`,
    );
  }

  // Set environment before loading modules
  process.env.PORT = String(PORT);
  process.env.OIDC_ISSUER = tunnelUrl.toString();

  const HANDOFF_TOKEN = "test-handoff-" + randomUUID();
  process.env.HANDOFF_TOKEN = HANDOFF_TOKEN;

  // Load modules (they read process.env at import time)
  const lib_express = await import("../../routes/express.js");
  const lib_apub_utils = await import("../../apub/utils.js");
  const lib_apub_federation = await import("../../apub/federation.js");

  const Express: TExpress = lib_express.app;
  const APub = lib_apub_utils.APub;
  const federation: Federation<any> = lib_apub_federation.federation;
  const USER_IDENTIFIER: string = lib_apub_federation.USER_IDENTIFIER;

  const ourHandle =
    `${USER_IDENTIFIER}@${tunnelUrl.host}` as `${string}@${string}`;

  const activeFederation = new AbortController();
  process.once("SIGINT", () => activeFederation?.abort());
  void federation.startQueue(undefined, {
    signal: activeFederation.signal,
  });

  const activeExpress: Server = await new Promise((res) => {
    const srv = Express.listen(PORT, () => res(srv));
  });

  console.log(`[ActorServer] Express running on localhost:${PORT}`);
  console.log(`[ActorServer] Actor handle: ${ourHandle}`);

  const result: ActorServerResult = {
    url: tunnelUrl,
    handle: ourHandle,
    federation,
    APub,
    USER_IDENTIFIER,
  };

  const stop = async () => {
    if (activeTunnel) {
      await activeTunnel.close();
    }
    await new Promise<void>((res) => {
      activeExpress.close(() => res());
    });
    activeFederation.abort();
    console.log("[ActorServer] Stopped");
  };

  return { result, stop };
}
+146 −0

File added.

Preview size limit exceeded, changes collapsed.

+2 −0
Original line number Diff line number Diff line
@@ -6,6 +6,8 @@ import { APubLive } from "./utils.live.js";
import { APubStub } from "./utils.stub.js";
import { IProfile } from "../lib/instance/userMeta.js";

export type APub = (new (...args: any) => IAPubUtils) & IAPubUtils_Static;

export const APub: (new (...args: any) => IAPubUtils) & IAPubUtils_Static =
  Flags.Dev.UseAPubStub ? APubStub : APubLive;

Loading