Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
345
apps/api/src/connections/connections.integration.test.ts
Normal file
345
apps/api/src/connections/connections.integration.test.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
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 { 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 { 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 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;
|
||||
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;
|
||||
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 }));
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue