T-HUB/apps/api/src/connections/connections.integration.test.ts
hacklab 80aa198843 feat: wire the Instagram connector into connect and publishing
The Instagram connector existed but was never registered, so Channels showed
it as "provider unavailable" and publishing could not resolve it.

- Register createInstagramConnector in the connections registry and the
  publishing registry; add the connector-runtime publishing policy
  (idempotency "none": media_publish has no key, never replay).
- The Facebook app credentials identify the Hub, not a collective: read them
  only from INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET. Drop the
  clientId/clientSecret connect fields and stop copying the app secret into
  each connection's vault entry. The provider is connectable only when both
  are set (connectEnabled + connectNote).
- Fail the connect when several Instagram accounts are granted instead of
  silently publishing as the first Page's account.
- Fix the connector package's pre-existing lint errors (it now ships in api).
- Tests: contract cases for env-only credentials and the multi-account
  refusal; connections integration case for provider gating and the
  Facebook dialog redirect.
- docs/oauth-connect.md: Instagram setup, callback URI and limits.

Not in this change: publishing images needs a public plaintext JPEG URL that
Meta downloads, and storage is client-side encrypted, so presigned S3 URLs
cannot serve it. Until a short-lived media endpoint exists, publishing to
Instagram fails with an explicit error. Token refresh via ig_refresh_token
on graph.facebook.com also needs checking against Facebook Login tokens.
2026-10-15 14:15:03 +02:00

394 lines
25 KiB
TypeScript

import "reflect-metadata";
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
import { createHash, randomUUID } from "node:crypto";
import { Module, UnauthorizedException, type ExecutionContext, type INestApplication } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectorConnection, NormalizedIdentity } from "@tactical-media-broadcast-hub/core";
import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
import { createMastodonConnector } from "../../../../packages/connector-mastodon/src/index.js";
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
import { connectionRegistry } from "./connector-registry.js";
import { Vault, type SealedCredential } from "../vault/vault.js";
import type { SessionPayload } from "../auth/session.js";
import { RbacGuard } from "../rbac/rbac.guard.js";
import { logger } from "../telemetry/logger.js";
import { ConnectionsController } from "./connections.controller.js";
import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js";
import type { PublishGovernance } from "../publishing/publish-governance.js";
import type { ConnectionStore, StoredConnection } from "./connection-store.js";
import { RedisPendingStore } from "./pending.js";
import { MemoryPendingRedis } from "./testing.js";
import { injectHttp } from "./http-test-transport.js";
class MemoryConnections implements ConnectionStore {
readonly rows = new Map<string, StoredConnection>();
readonly audit: Array<Record<string, string>> = [];
authorized = true;
async assertAuthorized(session: SessionPayload): Promise<void> {
if (!this.authorized || session.workspaceId !== sessionFixture.workspaceId) throw new Error("membership revoked");
}
async list(workspaceId: string): Promise<ConnectionSummary[]> {
return [...this.rows.values()].filter(({ connection }) => connection.workspaceId === workspaceId).map(({ connection, credential }) => ({
id: connection.id, provider: connection.provider, channelId: connection.channelId, fields: { ...connection.fields },
health: credential ? "unavailable" : "needs_credentials", credentialRotatedAt: null, recentPublishes: [], createdAt: connection.createdAt.toISOString(),
}));
}
async get(workspaceId: string, id: string): Promise<StoredConnection> {
const row = this.rows.get(id);
if (!row || row.connection.workspaceId !== workspaceId) throw new Error("connection not found");
return row;
}
async save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void> {
await this.assertAuthorized(session);
if (reconnect && !this.rows.has(connection.id)) throw new Error("connection removed");
this.rows.set(connection.id, { connection: { ...connection, fields: { ...connection.fields, remoteId: identity.remoteId, handle: identity.handle } }, credential });
this.audit.push({ action: reconnect ? "connection.reconnect" : "connection.connect", provider: connection.provider });
}
async remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void> {
await this.assertAuthorized(session);
await revoke(await this.get(session.workspaceId, id));
this.rows.delete(id);
this.audit.push({ action: "connection.remove" });
}
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
await this.assertAuthorized(session);
const row = await this.get(session.workspaceId, id);
const fields = { ...row.connection.fields };
delete fields.publishGovernance;
row.connection.fields = mode === null ? fields : { ...fields, publishGovernance: mode };
this.audit.push({ action: "connection.publish_governance" });
}
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
const row = await this.get(workspaceId, id);
if (row.credential?.ciphertext.equals(previous.ciphertext)) {
row.credential = credential;
this.audit.push({ action: "credential.refresh" });
}
}
}
const sessionFixture: SessionPayload = {
userId: randomUUID(), workspaceId: randomUUID(), role: "Owner", authzEpoch: 1,
issuedAt: Math.floor(Date.now() / 1000), expiresAt: Math.floor(Date.now() / 1000) + 3600,
};
@Module({})
class TestModule {}
async function form(request: IncomingMessage): Promise<URLSearchParams> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk as Uint8Array));
return new URLSearchParams(Buffer.concat(chunks).toString());
}
function json(response: ServerResponse, value: unknown, status = 200): void {
response.writeHead(status, { "content-type": "application/json" }); response.end(JSON.stringify(value));
}
describe("HTTP OAuth connect with real connectors and mock authorization server", () => {
let mock: Server;
let origin: string;
let apiOrigin: string;
let app: INestApplication;
const store = new MemoryConnections();
const redis = new MemoryPendingRedis();
const encryption = new Vault();
const activeTokens = new Set<string>();
const grants = new Map<string, { challenge: string; redirectUri: string }>();
const revokedTokens: string[] = [];
const guard = vi.spyOn(RbacGuard.prototype, "canActivate").mockImplementation(async (context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<{ headers: Record<string, string>; session?: SessionPayload }>();
if (request.headers.authorization !== "Bearer test-session") throw new UnauthorizedException();
request.session = sessionFixture; return true;
});
const warn = vi.spyOn(logger, "warn").mockImplementation(() => undefined);
const info = vi.spyOn(logger, "info").mockImplementation(() => undefined);
let nextAccount = "account-1";
let issued = 0;
let failExchange = false;
let bridgeConfigured = false;
let instagramConfigured = false;
const botToken = "12345:bot_token_secret";
beforeEach(() => {
store.rows.clear(); store.audit.length = 0; store.authorized = true;
redis.entries.clear(); activeTokens.clear(); grants.clear(); revokedTokens.length = 0;
nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false; instagramConfigured = false;
warn.mockClear(); info.mockClear();
});
beforeAll(async () => {
mock = createServer((request, response) => { void (async () => {
const url = new URL(request.url ?? "/", origin);
if (url.pathname === "/api/v1/apps") {
const body = await form(request);
expect(body.get("redirect_uris")).toBe(`${apiOrigin}/connections/callback`);
json(response, { client_id: "client-id", client_secret: "registered-client-secret" });
} else if (url.pathname === "/oauth/authorize") {
const code = randomUUID();
grants.set(code, { challenge: url.searchParams.get("code_challenge") ?? "", redirectUri: url.searchParams.get("redirect_uri") ?? "" });
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "");
redirect.searchParams.set("state", url.searchParams.get("state") ?? "");
redirect.searchParams.set("code", code);
response.writeHead(302, { location: redirect.toString() }); response.end();
} else if (url.pathname === "/oauth/token") {
const body = await form(request);
const code = body.get("code") ?? "";
const grant = grants.get(code);
grants.delete(code);
if (failExchange || !grant || grant.redirectUri !== body.get("redirect_uri") || grant.challenge !== createHash("sha256").update(body.get("code_verifier") ?? "").digest("base64url") || body.get("client_secret") !== "registered-client-secret") {
json(response, { error: "secret echoed: registered-client-secret token-secret-leak" }, 400); return;
}
const token = `access-token-secret-${++issued}`;
activeTokens.add(token);
json(response, { access_token: token, refresh_token: `refresh-token-secret-${issued}`, token_type: "Bearer", scope: "read write:statuses write:media", created_at: Date.now() / 1000 });
} else if (url.pathname === "/api/v1/accounts/verify_credentials") {
const token = (request.headers.authorization ?? "").slice(7);
if (!activeTokens.has(token)) { json(response, { error: `revoked ${token}` }, 401); return; }
json(response, { id: nextAccount, username: "alice", acct: "alice", display_name: "Alice", avatar: "", url: `${origin}/@alice` });
} else if (url.pathname === "/api/v1/oauth-clients/local") {
json(response, { client_id: "client-id", client_secret: "registered-client-secret" });
} else if (url.pathname === "/api/v1/users/me") {
const token = (request.headers.authorization ?? "").slice(7);
if (!activeTokens.has(token)) { json(response, { error: "invalid token" }, 401); return; }
json(response, { id: 1, username: "alice", account: { id: 1, name: "alice", host: "127.0.0.1", displayName: "Alice" } });
} else if (url.pathname === "/api/v1/users/token") {
const body = await form(request);
if (body.get("grant_type") !== "refresh_token" || body.get("client_secret") !== "registered-client-secret") { json(response, { error: "invalid refresh" }, 401); return; }
const token = `access-token-secret-${++issued}`;
activeTokens.add(token);
json(response, { access_token: token, refresh_token: `refresh-token-secret-${issued}` });
} else if (url.pathname === "/api/v1/users/revoke-token") {
const token = (request.headers.authorization ?? "").slice(7);
revokedTokens.push(token); activeTokens.delete(token); json(response, {});
} else if (url.pathname === "/oauth/revoke") {
const body = await form(request);
const token = body.get("token") ?? "";
revokedTokens.push(token); activeTokens.delete(token); json(response, {});
} else if (url.pathname.startsWith("/bot")) {
if (!url.pathname.startsWith(`/bot${botToken}/`)) { json(response, { ok: false, error_code: 401, description: `unauthorized ${url.pathname}` }); return; }
json(response, { ok: true, result: { id: 12345, is_bot: true, username: "test_bot", first_name: "Test bot" } });
} else { json(response, { error: "not found" }, 404); }
})().catch(() => json(response, { error: "mock failure" }, 500)); });
origin = "http://127.0.0.1:4500";
const deps: ConnectionDependencies = {
renewSession: async () => "replacement-session",
store, pending: new RedisPendingStore(redis, encryption), encryption,
appUrl: "http://localhost:3001", callbackUrl: "",
registry: (credentials) => {
const registry = new ConnectorRegistry();
registry.register(createMastodonConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000 }));
registry.register(createPeerTubeConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000,
env: bridgeConfigured ? { PEERTUBE_AUTHORIZE_PATH: "/oauth/authorize", PEERTUBE_TOKEN_PATH: "/oauth/token" } : {},
}));
const botFetch: typeof fetch = (input, init) => fetch(String(input).replace("https://api.telegram.org", origin), init);
registry.register(createTelegramConnector({ credentials, fetchImpl: botFetch, requestsPerSecond: 100_000 }));
registry.register(createInstagramConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000,
env: instagramConfigured ? { INSTAGRAM_CLIENT_ID: "hub-app-id", INSTAGRAM_CLIENT_SECRET: "hub-app-secret" } : {},
}));
return registry;
},
};
app = await NestFactory.create({ module: TestModule, controllers: [ConnectionsController], providers: [
ConnectionsService, { provide: CONNECTION_DEPENDENCIES, useValue: deps },
] }, { logger: false });
await app.init();
apiOrigin = "http://api.local";
deps.callbackUrl = `${apiOrigin}/connections/callback`;
});
afterAll(async () => {
await app?.close();
warn.mockRestore(); info.mockRestore(); guard.mockRestore();
});
const mockFetch: typeof globalThis.fetch = (input, init) => injectHttp((request, response) => { mock.emit("request", request, response); }, input, init);
const fetch: typeof globalThis.fetch = (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.origin === origin) return mockFetch(input, init);
if (url.origin !== apiOrigin) throw new Error("Unexpected test destination");
const adapter = app.getHttpAdapter();
return injectHttp((request, response) => { adapter.getInstance().handle(request, response); }, input, init);
};
async function api(path: string, method = "GET", body?: unknown): Promise<Response> {
return fetch(`${apiOrigin}/connections${path}`, { method, headers: { authorization: "Bearer test-session", "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
}
async function begin(id?: string, provider = "mastodon"): Promise<{ redirectUrl: string; cookie: string }> {
const response = id ? await api(`/${id}/reconnect`, "POST", {}) : await api("/authorize", "POST", { provider, instanceUrl: origin });
expect(response.status).toBe(201);
const { redirectUrl } = await response.json() as { redirectUrl: string };
expect(new URL(redirectUrl).searchParams.get("code_challenge_method")).toBe("S256");
return { redirectUrl, cookie: response.headers.get("set-cookie")?.split(";")[0] ?? "" };
}
async function finish(authorization: { redirectUrl: string; cookie: string }): Promise<Response> {
const consent = await fetch(authorization.redirectUrl, { redirect: "manual" });
expect(consent.status).toBe(302);
return fetch(consent.headers.get("location") ?? "", { redirect: "manual", headers: { cookie: authorization.cookie } });
}
async function listed(): Promise<ConnectionSummary[]> { return (await (await api("")).json()) as ConnectionSummary[]; }
it("authorize → provider → unauthenticated callback → healthy list; reconnect rotates; remove revokes and clears row + vault envelope", async () => {
const auth = await begin();
expect(auth.cookie).toMatch(/^ap_connect_/);
expect(JSON.stringify([...redis.entries.values()])).not.toContain("registered-client-secret");
const callback = await finish(auth);
expect(callback.status).toBe(303);
expect(callback.headers.get("location")).toBe("http://localhost:3001/connections?connect=success");
expect(callback.headers.get("set-cookie")).toContain("Expires=");
const rows = await listed();
expect(rows).toHaveLength(1); expect(rows[0]?.health).toBe("healthy");
expect(rows[0]?.capabilities).toContain("publishText");
const id = rows[0]?.id ?? "";
const before = store.rows.get(id)?.credential?.ciphertext;
expect(before).toBeDefined();
const stored = store.rows.get(id)?.credential;
if (!stored) throw new Error("credential missing");
expect((await new Vault().open(sessionFixture.workspaceId, id, stored)).toString()).toContain("access-token-secret-1");
expect(JSON.stringify(rows)).not.toContain("token-secret");
const second = await finish(await begin(id));
expect(second.headers.get("location")).toContain("connect=success");
expect((await listed())[0]?.id).toBe(id);
expect(store.rows.get(id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(false);
expect(store.audit.at(-1)?.action).toBe("connection.reconnect");
const replay = await finish(auth);
expect(replay.headers.get("location")).toContain("connect=failure");
expect((await api(`/${id}`, "DELETE")).status).toBe(200);
expect(revokedTokens).toContain("access-token-secret-2");
expect(await listed()).toHaveLength(0); expect(store.rows.size).toBe(0); expect(redis.entries.size).toBe(0);
const observable = JSON.stringify({ audit: store.audit, warn: warn.mock.calls, info: info.mock.calls });
for (const secret of ["access-token-secret", "refresh-token-secret", "registered-client-secret"]) expect(observable).not.toContain(secret);
});
it("rejects missing auth on authorize, missing/wrong browser cookie and preserves a valid pending state", async () => {
expect((await fetch(`${apiOrigin}/connections/authorize`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: "mastodon", instanceUrl: origin }) })).status).toBe(401);
const auth = await begin();
const consent = await fetch(auth.redirectUrl, { redirect: "manual" });
const callbackUrl = consent.headers.get("location") ?? "";
const missing = await fetch(callbackUrl, { redirect: "manual" });
expect(missing.headers.get("location")).toContain("connect=failure");
const wrong = await fetch(callbackUrl, { redirect: "manual", headers: { cookie: auth.cookie.replace(sessionFixture.workspaceId, randomUUID()) } });
expect(wrong.headers.get("location")).toContain("connect=failure");
const good = await fetch(callbackUrl, { redirect: "manual", headers: { cookie: auth.cookie } });
expect(good.headers.get("location")).toContain("connect=success");
});
it("failed exchange and different-account reconnect retain the previous encrypted credential; health detects revoked tokens", async () => {
await finish(await begin());
const row = (await listed())[0];
if (!row) throw new Error("connection missing");
const before = store.rows.get(row.id)?.credential?.ciphertext;
failExchange = true;
const failure = await finish(await begin(row.id));
failExchange = false;
expect(failure.headers.get("location")).toContain("connect=failure");
expect(store.rows.get(row.id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(true);
nextAccount = "other-account";
const wrongAccount = await finish(await begin(row.id));
nextAccount = "account-1";
expect(wrongAccount.headers.get("location")).toContain("connect=failure");
expect(store.rows.get(row.id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(true);
activeTokens.clear();
expect((await listed())[0]?.health).toBe("needs_credentials");
expect((await finish(await begin(row.id))).headers.get("location")).toContain("connect=success");
expect((await listed())[0]?.health).toBe("healthy");
expect(JSON.stringify(warn.mock.calls)).not.toContain("token-secret-leak");
expect(JSON.stringify(warn.mock.calls)).not.toContain("registered-client-secret");
});
it("rechecks workspace membership during callback, handles denial, and does not expose provider errors", async () => {
const revoked = await begin();
store.authorized = false;
expect((await finish(revoked)).headers.get("location")).toContain("connect=failure");
store.authorized = true;
const denied = await begin();
const url = new URL(`${apiOrigin}/connections/callback`);
url.searchParams.set("state", new URL(denied.redirectUrl).searchParams.get("state") ?? "");
url.searchParams.set("error", "access_denied token-secret-leak");
const response = await fetch(url, { redirect: "manual", headers: { cookie: denied.cookie } });
expect(response.headers.get("location")).toContain("connect=failure");
expect(JSON.stringify(warn.mock.calls)).not.toContain("token-secret-leak");
});
it("validates Telegram getMe, encrypts tokens, rotates in place and removes locally; PeerTube is capability-honest", async () => {
const bad = await api("/telegram/bot-token", "POST", { token: "12345:bad_secret" });
expect(bad.status).toBe(400); expect(await bad.text()).not.toContain("bad_secret");
const response = await api("/telegram/bot-token", "POST", { token: botToken, channelId: "@target" });
expect(response.status).toBe(201);
const bot = (await listed()).find((row) => row.provider === "telegram");
expect(bot?.health).toBe("healthy"); expect(bot?.channelId).toBe("@target");
const id = bot?.id ?? "";
const before = store.rows.get(id)?.credential?.ciphertext;
expect((await api("/telegram/bot-token", "POST", { token: botToken, connectionId: id })).status).toBe(201);
expect(store.rows.get(id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(false);
expect((await listed()).filter((row) => row.provider === "telegram")).toHaveLength(1);
expect((await api(`/${id}`, "DELETE")).status).toBe(200);
expect(store.rows.has(id)).toBe(false);
const providers = await (await api("/providers")).json() as Array<{ id: string; enabled: boolean; note?: string }>;
expect(providers.find((provider) => provider.id === "peertube")?.enabled).toBe(false);
expect((await api("/authorize", "POST", { provider: "peertube", instanceUrl: origin })).status).toBe(400);
expect(JSON.stringify({ audit: store.audit, warn: warn.mock.calls, info: info.mock.calls })).not.toContain(botToken);
});
it("runs PeerTube code flow only with an explicitly configured instance bridge", async () => {
bridgeConfigured = true;
expect((await finish(await begin(undefined, "peertube"))).headers.get("location")).toContain("connect=success");
const row = (await listed()).find((connection) => connection.provider === "peertube");
expect(row?.health).toBe("healthy");
const id = row?.id ?? "";
expect((await finish(await begin(id))).headers.get("location")).toContain("connect=success");
expect((await listed()).filter((connection) => connection.id === id)).toHaveLength(1);
const stored = store.rows.get(id)?.credential;
if (!stored) throw new Error("PeerTube credential missing");
const secrets = JSON.parse((await encryption.open(sessionFixture.workspaceId, id, stored)).toString()) as Record<string, string>;
activeTokens.delete(secrets.access_token ?? "");
expect((await listed()).find((connection) => connection.id === id)?.health).toBe("healthy");
expect(store.audit.at(-1)?.action).toBe("credential.refresh");
expect(store.rows.get(id)?.credential?.ciphertext.equals(stored.ciphertext)).toBe(false);
expect((await api(`/${id}`, "DELETE")).status).toBe(200);
bridgeConfigured = false;
});
it("offers Instagram only with the Hub's Facebook app configured, and keeps the app secret out of the browser", async () => {
expect(connectionRegistry({} as never).has("instagram")).toBe(true);
const providersOf = async () => await (await api("/providers")).json() as Array<{ id: string; enabled: boolean; note?: string }>;
expect((await providersOf()).find((provider) => provider.id === "instagram")).toMatchObject({ enabled: false, note: expect.stringContaining("INSTAGRAM_CLIENT_ID") });
expect((await api("/authorize", "POST", { provider: "instagram" })).status).toBe(400);
instagramConfigured = true;
expect((await providersOf()).find((provider) => provider.id === "instagram")?.enabled).toBe(true);
const response = await api("/authorize", "POST", { provider: "instagram" });
expect(response.status).toBe(201);
const { redirectUrl } = await response.json() as { redirectUrl: string };
const dialog = new URL(redirectUrl);
expect(dialog.origin + dialog.pathname).toBe("https://www.facebook.com/v21.0/dialog/oauth");
expect(dialog.searchParams.get("client_id")).toBe("hub-app-id");
expect(dialog.searchParams.get("redirect_uri")).toBe(`${apiOrigin}/connections/callback`);
expect(dialog.searchParams.get("state")).toMatch(/.{32,}/);
expect(redirectUrl).not.toContain("hub-app-secret");
instagramConfigured = false;
});
it("sets, validates and clears per-connection publish governance", async () => {
await finish(await begin());
const id = (await listed())[0]?.id ?? "";
const set = await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "admin_majority" });
expect(set.status).toBe(200);
expect(await set.json()).toEqual({ connectionId: id, publishGovernance: "admin_majority" });
expect((await listed())[0]?.fields.publishGovernance).toBe("admin_majority");
expect(store.audit.at(-1)?.action).toBe("connection.publish_governance");
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "everyone" })).status).toBe(400);
expect((await api(`/${id}/publish-governance`, "PATCH", {})).status).toBe(400);
expect((await api("/not-a-uuid/publish-governance", "PATCH", { publishGovernance: "auto" })).status).toBe(400);
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: null })).status).toBe(200);
expect((await listed())[0]?.fields).not.toHaveProperty("publishGovernance");
});
it("does not resurrect a connection removed while reconnect consent is pending", async () => {
await finish(await begin());
const row = (await listed())[0];
if (!row) throw new Error("connection missing");
const pending = await begin(row.id);
expect((await api(`/${row.id}`, "DELETE")).status).toBe(200);
expect((await finish(pending)).headers.get("location")).toContain("connect=failure");
expect(store.rows.has(row.id)).toBe(false);
});
});