diff --git a/packages/zulip/bot-dm-guard.test.ts b/packages/zulip/bot-dm-guard.test.ts index 958da2f..1aa0ac8 100644 --- a/packages/zulip/bot-dm-guard.test.ts +++ b/packages/zulip/bot-dm-guard.test.ts @@ -1,18 +1,26 @@ -import { expect, test } from 'bun:test' -import { registerRealmHooks } from '@commy/testing/realm-hooks' -import { FetchHttpClient, HttpClient } from '@effect/platform' +/** + * bot-dm-guard wrapper LOGIC, exercised through a real `ZulipHttp` whose + * underlying `HttpClient` is the **owned-fake stub** — no `Bun.serve`, no + * real socket. The wrapper sits on every bot-authenticated `ZulipHttp` and + * its job is a decision: intercept `POST /messages` with `type=private` and + * reject when every non-self recipient is a bot, otherwise forward. That + * decision is ours to test against the seam we own; the stub gives the + * forwarded request something to land on so we can assert it was (or was not) + * sent. None of these are live-contract duplicates — real Zulip cannot be + * driven to bot↔bot rejection, and the allow-cases assert the guard's *allow + * decision*, not that `POST /messages` works. + */ + +import { expect } from 'bun:test' +import { effectTest } from '@commy/testing/effect-test' +import { makeStubHttpClient, type StubHttpClient } from '@commy/testing/stub-http-client' +import { HttpClient } from '@effect/platform' import { Cause, Effect, Exit, Option, Schema } from 'effect' import { BotToBotDirectMessageError, type RecipientDirectory, wrapBotHttp } from './bot-dm-guard.ts' import { ApiKey, BotEmail, makeZulipHttp, RealmUrl, type ZulipHttp } from './http.ts' -import type { TestRealm } from './test-server.ts' -import { startTestRealm } from './test-server.ts' import { ZulipUserRef } from './user-ref.ts' -let realm: TestRealm - -registerRealmHooks(startTestRealm, (next) => { - realm = next -}) +const REALM_URL = 'https://zulip.example.com' const SELF_ID = ZulipUserRef(100) const OTHER_BOT_ID = 200 @@ -36,18 +44,30 @@ const sentMessageSchema = Schema.Struct({ id: Schema.Int, }) -const httpClient = Effect.runSync(HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))) - -const buildHttp = (): Effect.Effect => +const buildHttp = (stub: StubHttpClient): Effect.Effect => Effect.gen(function* () { - const realmUrl = yield* RealmUrl(realm.url) + const realmUrl = yield* RealmUrl(REALM_URL) const email = yield* BotEmail('self-bot@example.com') const apiKey = yield* ApiKey('self-key') return yield* makeZulipHttp({ realmUrl, email, apiKey }) - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.orDie) + }).pipe(Effect.provideService(HttpClient.HttpClient, stub.client), Effect.orDie) -const messagesCaptured = (): number => - realm.captured.filter((r) => r.method === 'POST' && r.url.pathname === '/api/v1/messages').length +const seedMessageOk = (stub: StubHttpClient, id: number): Effect.Effect => + stub.respond('POST', '/api/v1/messages', { body: { result: 'success', id } }) + +const requestsTo = ( + stub: StubHttpClient, + method: string, + pathname: string, +): Effect.Effect => + stub.captured.pipe( + Effect.map( + (reqs) => reqs.filter((r) => r.method === method && r.url.pathname === pathname).length, + ), + ) + +const messagesPosted = (stub: StubHttpClient): Effect.Effect => + requestsTo(stub, 'POST', '/api/v1/messages') const expectBotDirectMessageDefect = (eff: Effect.Effect): Effect.Effect => Effect.gen(function* () { @@ -60,40 +80,42 @@ const expectBotDirectMessageDefect = (eff: Effect.Effect): Effect } }) -test('rejects /messages POST with type=private and a single bot recipient', () => - Effect.runPromise( - Effect.gen(function* () { - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) - yield* expectBotDirectMessageDefect( - http.post('/messages', sentMessageSchema, { - type: 'private', - to: JSON.stringify([OTHER_BOT_ID]), - content: 'hi', - }), - ) - expect(messagesCaptured()).toBe(0) - }), - )) +effectTest('rejects /messages POST with type=private and a single bot recipient', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) + yield* expectBotDirectMessageDefect( + http.post('/messages', sentMessageSchema, { + type: 'private', + to: JSON.stringify([OTHER_BOT_ID]), + content: 'hi', + }), + ) + expect(yield* messagesPosted(stub)).toBe(0) + }), +) -test('rejects /messages POST with type=private and multiple bot recipients', () => - Effect.runPromise( - Effect.gen(function* () { - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) - yield* expectBotDirectMessageDefect( - http.post('/messages', sentMessageSchema, { - type: 'private', - to: JSON.stringify([OTHER_BOT_ID, SECOND_BOT_ID]), - content: 'hi', - }), - ) - expect(messagesCaptured()).toBe(0) - }), - )) +effectTest('rejects /messages POST with type=private and multiple bot recipients', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) + yield* expectBotDirectMessageDefect( + http.post('/messages', sentMessageSchema, { + type: 'private', + to: JSON.stringify([OTHER_BOT_ID, SECOND_BOT_ID]), + content: 'hi', + }), + ) + expect(yield* messagesPosted(stub)).toBe(0) + }), +) -test('rejects /messages POST with type=private when self is included alongside bots only', () => - Effect.runPromise( +effectTest( + 'rejects /messages POST with type=private when self is included alongside bots only', + () => Effect.gen(function* () { - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) + const stub = yield* makeStubHttpClient + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) yield* expectBotDirectMessageDefect( http.post('/messages', sentMessageSchema, { type: 'private', @@ -101,134 +123,113 @@ test('rejects /messages POST with type=private when self is included alongside b content: 'hi', }), ) - expect(messagesCaptured()).toBe(0) + expect(yield* messagesPosted(stub)).toBe(0) }), - )) +) -test('allows /messages POST with type=private to a single human recipient', () => - Effect.runPromise( - Effect.gen(function* () { - realm.handle('POST', '/api/v1/messages', () => ({ - body: { result: 'success', id: 1 }, - })) - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) - yield* http.post('/messages', sentMessageSchema, { - type: 'private', - to: JSON.stringify([HUMAN_ID]), - content: 'hi', - }) - expect(messagesCaptured()).toBe(1) - }), - )) +effectTest('allows /messages POST with type=private to a single human recipient', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* seedMessageOk(stub, 1) + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) + yield* http.post('/messages', sentMessageSchema, { + type: 'private', + to: JSON.stringify([HUMAN_ID]), + content: 'hi', + }) + expect(yield* messagesPosted(stub)).toBe(1) + }), +) -test('allows /messages POST with type=private to a mixed bot+human group', () => - Effect.runPromise( - Effect.gen(function* () { - realm.handle('POST', '/api/v1/messages', () => ({ - body: { result: 'success', id: 2 }, - })) - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) - yield* http.post('/messages', sentMessageSchema, { - type: 'private', - to: JSON.stringify([OTHER_BOT_ID, HUMAN_ID]), - content: 'hi', - }) - expect(messagesCaptured()).toBe(1) - }), - )) +effectTest('allows /messages POST with type=private to a mixed bot+human group', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* seedMessageOk(stub, 2) + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) + yield* http.post('/messages', sentMessageSchema, { + type: 'private', + to: JSON.stringify([OTHER_BOT_ID, HUMAN_ID]), + content: 'hi', + }) + expect(yield* messagesPosted(stub)).toBe(1) + }), +) -test('allows /messages POST with type=private to self only (no non-self recipients)', () => - Effect.runPromise( - Effect.gen(function* () { - realm.handle('POST', '/api/v1/messages', () => ({ - body: { result: 'success', id: 3 }, - })) - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) - yield* http.post('/messages', sentMessageSchema, { - type: 'private', - to: JSON.stringify([SELF_ID]), - content: 'note to self', - }) - expect(messagesCaptured()).toBe(1) - }), - )) +effectTest('allows /messages POST with type=private to self only (no non-self recipients)', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* seedMessageOk(stub, 3) + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) + yield* http.post('/messages', sentMessageSchema, { + type: 'private', + to: JSON.stringify([SELF_ID]), + content: 'note to self', + }) + expect(yield* messagesPosted(stub)).toBe(1) + }), +) -test('allows /messages POST with type=channel regardless of recipient lookup', () => - Effect.runPromise( - Effect.gen(function* () { - realm.handle('POST', '/api/v1/messages', () => ({ - body: { result: 'success', id: 4 }, - })) - let directoryCalls = 0 - const trackedDirectory = (): Effect.Effect => { - directoryCalls += 1 - return directoryStub() - } - const http = wrapBotHttp(yield* buildHttp(), trackedDirectory, SELF_ID) - yield* http.post('/messages', sentMessageSchema, { - type: 'channel', - to: 'general', - topic: 'x', - content: 'hi', - }) - expect(messagesCaptured()).toBe(1) - expect(directoryCalls).toBe(0) - }), - )) +effectTest('allows /messages POST with type=channel regardless of recipient lookup', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* seedMessageOk(stub, 4) + let directoryCalls = 0 + const trackedDirectory = (): Effect.Effect => { + directoryCalls += 1 + return directoryStub() + } + const http = wrapBotHttp(yield* buildHttp(stub), trackedDirectory, SELF_ID) + yield* http.post('/messages', sentMessageSchema, { + type: 'channel', + to: 'general', + topic: 'x', + content: 'hi', + }) + expect(yield* messagesPosted(stub)).toBe(1) + expect(directoryCalls).toBe(0) + }), +) -test('non-/messages POSTs pass through unchanged', () => - Effect.runPromise( - Effect.gen(function* () { - realm.handle('POST', '/api/v1/messages/123/reactions', () => ({ - body: { result: 'success' }, - })) - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) - yield* http.post('/messages/123/reactions', successSchema, { emoji_name: 'tada' }) - const reactionPosts = realm.captured.filter( - (r) => r.method === 'POST' && r.url.pathname === '/api/v1/messages/123/reactions', - ) - expect(reactionPosts.length).toBe(1) - }), - )) +effectTest('non-/messages POSTs pass through unchanged', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('POST', '/api/v1/messages/123/reactions', { body: { result: 'success' } }) + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) + yield* http.post('/messages/123/reactions', successSchema, { emoji_name: 'tada' }) + expect(yield* requestsTo(stub, 'POST', '/api/v1/messages/123/reactions')).toBe(1) + }), +) -test('GET requests pass through unchanged', () => - Effect.runPromise( - Effect.gen(function* () { - realm.handle('GET', '/api/v1/users', () => ({ - body: { result: 'success', members: [] }, - })) - const usersResponseSchema = Schema.Struct({ - result: Schema.Literal('success'), - members: Schema.Array(Schema.Unknown), - }) - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) - yield* http.get('/users', usersResponseSchema) - const userGets = realm.captured.filter( - (r) => r.method === 'GET' && r.url.pathname === '/api/v1/users', - ) - expect(userGets.length).toBe(1) - }), - )) +effectTest('GET requests pass through unchanged', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users', { body: { result: 'success', members: [] } }) + const usersResponseSchema = Schema.Struct({ + result: Schema.Literal('success'), + members: Schema.Array(Schema.Unknown), + }) + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) + yield* http.get('/users', usersResponseSchema) + expect(yield* requestsTo(stub, 'GET', '/api/v1/users')).toBe(1) + }), +) -test('DELETE requests pass through unchanged', () => - Effect.runPromise( - Effect.gen(function* () { - realm.handle('DELETE', '/api/v1/messages/123/reactions', () => ({ - body: { result: 'success' }, - })) - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) - yield* http.delete('/messages/123/reactions', successSchema, { emoji_name: 'tada' }) - const reactionDeletes = realm.captured.filter( - (r) => r.method === 'DELETE' && r.url.pathname === '/api/v1/messages/123/reactions', - ) - expect(reactionDeletes.length).toBe(1) - }), - )) +effectTest('DELETE requests pass through unchanged', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('DELETE', '/api/v1/messages/123/reactions', { body: { result: 'success' } }) + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) + yield* http.delete('/messages/123/reactions', successSchema, { emoji_name: 'tada' }) + expect(yield* requestsTo(stub, 'DELETE', '/api/v1/messages/123/reactions')).toBe(1) + }), +) -test('rejects /messages POST with type=private when `to` cannot be parsed as a user-id list', () => - Effect.runPromise( +effectTest( + 'rejects /messages POST with type=private when `to` cannot be parsed as a user-id list', + () => Effect.gen(function* () { - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) + const stub = yield* makeStubHttpClient + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) yield* expectBotDirectMessageDefect( http.post('/messages', sentMessageSchema, { type: 'private', @@ -236,14 +237,16 @@ test('rejects /messages POST with type=private when `to` cannot be parsed as a u content: 'hi', }), ) - expect(messagesCaptured()).toBe(0) + expect(yield* messagesPosted(stub)).toBe(0) }), - )) +) -test('rejects /messages POST with type=private when `to` is a JSON array of non-integers', () => - Effect.runPromise( +effectTest( + 'rejects /messages POST with type=private when `to` is a JSON array of non-integers', + () => Effect.gen(function* () { - const http = wrapBotHttp(yield* buildHttp(), directoryStub, SELF_ID) + const stub = yield* makeStubHttpClient + const http = wrapBotHttp(yield* buildHttp(stub), directoryStub, SELF_ID) yield* expectBotDirectMessageDefect( http.post('/messages', sentMessageSchema, { type: 'private', @@ -251,6 +254,6 @@ test('rejects /messages POST with type=private when `to` is a JSON array of non- content: 'hi', }), ) - expect(messagesCaptured()).toBe(0) + expect(yield* messagesPosted(stub)).toBe(0) }), - )) +)