diff --git a/server/graphql/modules/manualReviewTool.ts b/server/graphql/modules/manualReviewTool.ts index 94c22aec6..2a9c95953 100644 --- a/server/graphql/modules/manualReviewTool.ts +++ b/server/graphql/modules/manualReviewTool.ts @@ -1687,10 +1687,7 @@ const NcmecManualReviewJobPayload: GQLNcmecManualReviewJobPayloadResolvers = { typeSelector: ncmecContentItemSubmission.contentItem.itemTypeIdentifier, }); - if ( - type === undefined || - (type.kind !== 'CONTENT' && type.kind !== 'USER') - ) { + if (type === undefined) { throw new Error( `No Content Item Type found for id: ${ncmecContentItemSubmission.contentItem.itemTypeIdentifier.id}`, ); diff --git a/server/test/fixtureHelpers/createThreadItemTypes.ts b/server/test/fixtureHelpers/createThreadItemTypes.ts new file mode 100644 index 000000000..3ed90a117 --- /dev/null +++ b/server/test/fixtureHelpers/createThreadItemTypes.ts @@ -0,0 +1,64 @@ +import { faker } from '@faker-js/faker'; +import { ScalarTypes, type Field } from '@roostorg/coop-types'; + +import { type Dependencies } from '../../iocContainer/index.js'; +import { type NonEmptyArray } from '../../utils/typescript-types.js'; + +export default async function (opts: { + moderationConfigService: Dependencies['ModerationConfigService']; + orgId: string; + numItemTypes?: number; + includeCreator?: boolean; + extra: { fields?: NonEmptyArray }; +}) { + const { + moderationConfigService, + orgId, + extra, + numItemTypes = 1, + includeCreator = false, + } = opts; + + const itemTypes = await Promise.all( + Array.from({ length: numItemTypes }).map(async () => + moderationConfigService.createThreadType(orgId, { + name: `${faker.lorem.words(2)}`, + description: faker.lorem.sentence(), + schema: extra.fields ?? [ + { + name: 'field1', + type: ScalarTypes.STRING, + required: false, + container: null, + }, + ...(includeCreator + ? [ + { + name: 'creatorId', + type: ScalarTypes.RELATED_ITEM, + required: false, + container: null, + }, + ] + : []), + ], + schemaFieldRoles: includeCreator + ? { + creatorId: 'creatorId', + } + : {}, + }), + ), + ); + + return { + itemTypes, + cleanup: async () => { + await Promise.all( + itemTypes.map(async (it) => + moderationConfigService.deleteItemType({ itemTypeId: it.id, orgId }), + ), + ); + }, + }; +} diff --git a/server/test/fixtureHelpers/createUser.ts b/server/test/fixtureHelpers/createUser.ts index 4d6e97678..5e2766af5 100644 --- a/server/test/fixtureHelpers/createUser.ts +++ b/server/test/fixtureHelpers/createUser.ts @@ -8,7 +8,10 @@ import { } from '../../graphql/datasources/userKyselyPersistence.js'; import { type CombinedPg } from '../../services/combinedDbTypes.js'; import { type LoginMethod } from '../../services/coreAppTables.js'; -import { UserRole } from '../../services/userManagementService/index.js'; +import { + hashPassword, + UserRole, +} from '../../services/userManagementService/index.js'; import { logErrorAndThrow } from '../utils.js'; // SAML-only by default keeps the `password_null_when_not_present` CHECK @@ -22,12 +25,17 @@ export default async function createUser( id?: string; role?: UserRole; loginMethods?: readonly LoginMethod[]; + /** Plaintext; hashed before insert when provided. */ password?: string | null; + approvedByAdmin?: boolean; } = {}, ) { const userId = extra.id ?? uid(); const loginMethods = extra.loginMethods ?? DEFAULT_LOGIN_METHODS; - const password = extra.password ?? null; + const password = + extra.password != null && extra.password !== '' + ? await hashPassword(extra.password) + : null; const user = await kyselyUserInsert({ db, @@ -39,6 +47,7 @@ export default async function createUser( lastName: faker.name.lastName(), role: extra.role ?? UserRole.ADMIN, loginMethods, + approvedByAdmin: extra.approvedByAdmin, }).catch(logErrorAndThrow); return { diff --git a/server/test/fixtureHelpers/makeStubFetchHTTP.ts b/server/test/fixtureHelpers/makeStubFetchHTTP.ts new file mode 100644 index 000000000..96ac27763 --- /dev/null +++ b/server/test/fixtureHelpers/makeStubFetchHTTP.ts @@ -0,0 +1,88 @@ +import { Headers } from 'undici'; + +import { + type CoopRequestQuery, + type CoopResponse, + type FetchHTTP, + type HandleResponseBody, +} from '../../services/networkingService/index.js'; + +/** Shape of one recorded outgoing fetchHTTP call. */ +export type RecordedFetchHTTPCall = { + url: string; + method: string; + body: unknown; + headers?: Record>; +}; + +/** Records every outgoing fetchHTTP call and returns canned CyberTip + * responses. */ +export function makeStubFetchHTTP( + reportId: string, + fileId: string, + opts: { preservationUrl?: string } = {}, +): { + fetchHTTP: FetchHTTP; + calls: RecordedFetchHTTPCall[]; +} { + const calls: RecordedFetchHTTPCall[] = []; + const preservationUrl = opts.preservationUrl; + const ok = (body: unknown): CoopResponse => + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- the stub returns a canned body through a slot typed by the caller's T. + ({ + status: 200, + ok: true, + headers: new Headers(), + body, + }) as CoopResponse; + const fetchHTTP: FetchHTTP = async ( + query: CoopRequestQuery, + ): Promise> => { + const { url, method, body, headers } = query; + // eslint-disable-next-line functional/immutable-data -- request recorder mutates by design + calls.push({ url, method, body, headers }); + + // media download for #upload + if (method === 'get') { + const stream = new ReadableStream({ + start(ctr) { + ctr.enqueue(new TextEncoder().encode('fake-media-bytes')); + ctr.close(); + }, + }); + return ok(stream); + } + // NCMEC CyberTip protocol — every XML endpoint returns responseCode=0. + // /submit, /upload, /fileinfo use `reportResponse`; /finish uses + // `reportDoneResponse`. + if ( + url.endsWith('/ispws/submit') || + url.endsWith('/ispws/upload') || + url.endsWith('/ispws/fileinfo') + ) { + const isSubmit = url.endsWith('/ispws/submit'); + const isUpload = url.endsWith('/ispws/upload'); + return ok({ + reportResponse: { + responseCode: { _text: '0' }, + ...(isSubmit ? { reportId: { _text: reportId } } : {}), + ...(isUpload ? { fileId: { _text: fileId } } : {}), + }, + }); + } + if (url.endsWith('/ispws/finish')) { + return ok({ + reportDoneResponse: { + responseCode: { _text: '0' }, + reportId: { _text: reportId }, + files: [{ fileId: { _text: fileId } }], + }, + }); + } + if (preservationUrl != null && url === preservationUrl) { + return ok(undefined); + } + throw new Error(`stub fetchHTTP: unexpected request ${method} ${url}`); + }; + return { fetchHTTP, calls }; +} diff --git a/server/test/integ/ncmec-report-submission.integ.test.ts b/server/test/integ/ncmec-report-submission.integ.test.ts new file mode 100644 index 000000000..02bbc358b --- /dev/null +++ b/server/test/integ/ncmec-report-submission.integ.test.ts @@ -0,0 +1,427 @@ +/** + * Integration test: e2e testing for NCMEC + * + * Tests the full NCMEC flow including reporting with csam: true + * to the moderator dequeueing and then finally submitting the decision. + * + * Asserts that all types of media are correctly submitted to Cybertips. + * + * Run with: npm run test:integration + * Requires: `npm run up && npm run db:update` + */ +import { ScalarTypes } from '@roostorg/coop-types'; +import { uid } from 'uid'; + +import { jsonStringify } from '../../utils/encoding.js'; +import createContentItemTypes from '../fixtureHelpers/createContentItemTypes.js'; +import createMrtQueue from '../fixtureHelpers/createMrtQueue.js'; +import createOrg from '../fixtureHelpers/createOrg.js'; +import createThreadItemTypes from '../fixtureHelpers/createThreadItemTypes.js'; +import createUser from '../fixtureHelpers/createUser.js'; +import { makeStubFetchHTTP } from '../fixtureHelpers/makeStubFetchHTTP.js'; +import { + makeIntegrationServer, + type IntegrationServer, +} from './setupIntegrationServer.js'; +import { + waitFor, + waitForItemInScylla, + waitForJobCreationInPostgres, +} from './wait.js'; + +const MEDIA_URL = 'https://example.com/ncmec-submit-decision.jpg'; +const REVIEWER_PASSWORD = 'integ-reviewer-password-1'; + +describe('NCMEC report and submission (integration)', () => { + const orgId = uid(); + const ncmecReportId = uid(); + let harness: IntegrationServer | undefined; + let fetchStub: ReturnType; + let apiKey: string; + let userItemTypeId: string; + let queueId: string; + let reviewerEmail: string; + let reviewerId: string; + let orgCleanup: (() => Promise) | undefined; + let reviewerCleanup: (() => Promise) | undefined; + let queueCleanup: (() => Promise) | undefined; + + beforeAll(async () => { + fetchStub = makeStubFetchHTTP(ncmecReportId, 'f1'); + harness = await makeIntegrationServer({ + mockedDeps: { fetchHTTP: fetchStub.fetchHTTP }, + }); + + const orgFixture = await createOrg( + { + KyselyPg: harness.deps.KyselyPg, + ModerationConfigService: harness.deps.ModerationConfigService, + ApiKeyService: harness.deps.ApiKeyService, + }, + orgId, + ); + apiKey = orgFixture.apiKey; + userItemTypeId = orgFixture.defaultUserItemType.id; + orgCleanup = orgFixture.cleanup; + + const reviewerFixture = await createUser(harness.deps.KyselyPg, orgId, { + password: REVIEWER_PASSWORD, + loginMethods: ['password'], + approvedByAdmin: true, + }); + reviewerEmail = reviewerFixture.user.email; + reviewerId = reviewerFixture.user.id; + reviewerCleanup = reviewerFixture.cleanup; + + const queueFixture = await createMrtQueue({ + orgId, + mrtService: harness.deps.ManualReviewToolService, + userId: reviewerId, + }); + queueId = queueFixture.queue.id; + queueCleanup = queueFixture.cleanup; + + await harness.deps.NcmecService.updateNcmecOrgSettings({ + orgId, + username: 'espuser', + password: 'esppass', + contactEmail: 'reporter@example.com', + moreInfoUrl: null, + companyTemplate: 'AcmeESP', + legalUrl: 'https://acme.example/legal', + ncmecPreservationEndpoint: null, + ncmecAdditionalInfoEndpoint: null, + defaultNcmecQueueId: null, + defaultInternetDetailType: 'WEB_PAGE', + termsOfService: null, + contactPersonEmail: null, + contactPersonFirstName: null, + contactPersonLastName: null, + contactPersonPhone: null, + mediaReviewRequirement: 'ALL', + minMediaToReview: null, + }); + }, 60_000); + + afterAll(async () => { + try { + await queueCleanup?.(); + await reviewerCleanup?.(); + await orgCleanup?.(); + } finally { + await harness?.shutdown(); + } + }, 30_000); + + test('submitManualReviewDecision SUBMIT_NCMEC_REPORT via GQL triggers CyberTip submit', async () => { + if (!harness) throw new Error('harness was not initialized'); + + const contentTypeFixture = await createContentItemTypes({ + moderationConfigService: harness.deps.ModerationConfigService, + orgId, + includeCreator: true, + extra: { + fields: [ + { + name: 'image', + type: ScalarTypes.IMAGE, + required: false, + container: null, + }, + { + name: 'creatorId', + type: ScalarTypes.RELATED_ITEM, + required: true, + container: null, + }, + ], + }, + }); + const contentTypeId = contentTypeFixture.itemTypes[0].id; + + const threadTypeFixture = await createThreadItemTypes({ + moderationConfigService: harness.deps.ModerationConfigService, + orgId, + includeCreator: true, + extra: { + fields: [ + { + name: 'image', + type: ScalarTypes.IMAGE, + required: false, + container: null, + }, + { + name: 'creatorId', + type: ScalarTypes.RELATED_ITEM, + required: true, + container: null, + }, + ], + }, + }); + const threadTypeId = threadTypeFixture.itemTypes[0].id; + + const contentItemId = uid(); + const threadItemId = uid(); + const creatorUserId = uid(); + const reporterId = uid(); + const creatorRef = { id: creatorUserId, typeId: userItemTypeId }; + const THREAD_MEDIA_URL = + 'https://example.com/ncmec-submit-decision-thread.jpg'; + + try { + await harness.request + .post('/api/v1/items/async') + .set('x-api-key', apiKey) + .send({ + items: [ + { + id: contentItemId, + typeId: contentTypeId, + data: { image: MEDIA_URL, creatorId: creatorRef }, + }, + { + id: threadItemId, + typeId: threadTypeId, + data: { image: THREAD_MEDIA_URL, creatorId: creatorRef }, + }, + ], + }) + .expect(202); + + await waitForItemInScylla(harness.deps, { + orgId, + itemIdentifier: { id: contentItemId, typeId: contentTypeId }, + }); + await waitForItemInScylla(harness.deps, { + orgId, + itemIdentifier: { id: threadItemId, typeId: threadTypeId }, + }); + + await harness.request + .post('/api/v1/report') + .set('x-api-key', apiKey) + .send({ + reporter: { + kind: 'user', + typeId: userItemTypeId, + id: reporterId, + }, + reportedAt: new Date().toISOString(), + reportedForReason: { csam: true }, + reportedItem: { + id: contentItemId, + typeId: contentTypeId, + data: { image: MEDIA_URL, creatorId: creatorRef }, + }, + }) + .expect(201); + + await waitForJobCreationInPostgres(harness.deps, { + orgId, + itemIdentifier: { id: creatorUserId, typeId: userItemTypeId }, + }); + + // --- Submit decision via GraphQL API --- + const loginRes = await harness.request.post('/api/v1/graphql').send({ + query: `mutation { + login(input: { email: ${jsonStringify(reviewerEmail)}, password: ${jsonStringify(REVIEWER_PASSWORD)} }) { + __typename + } + }`, + }); + expect(loginRes.status).toBe(200); + expect(loginRes.body?.data?.login?.__typename).toBe( + 'LoginSuccessResponse', + ); + + const dequeueRes = await harness.request.post('/api/v1/graphql').send({ + query: `mutation { + dequeueManualReviewJob(queueId: ${jsonStringify(queueId)}) { + ... on DequeueManualReviewJobSuccessResponse { + job { + id + payload { + ... on NcmecManualReviewJobPayload { + allMediaItems { + isReported + contentItem { + __typename + ... on ItemBase { + id + type { id } + data + } + } + } + } + } + } + lockToken + } + } + }`, + }); + if (dequeueRes.status !== 200) { + throw new Error( + `dequeueManualReviewJob failed: HTTP ${dequeueRes.status} — ${jsonStringify(dequeueRes.body)}`, + ); + } + expect(dequeueRes.body.errors).toBeUndefined(); + const dequeueData = dequeueRes.body.data?.dequeueManualReviewJob; + if (!dequeueData) { + throw new Error('expected a job to dequeue'); + } + + const { + job: { id: jobId, payload }, + lockToken, + } = dequeueData; + + type AllMediaItem = { + isReported: boolean; + contentItem: { + __typename: string; + id: string; + type: { id: string }; + data: Record; + }; + }; + + const mediaByItemId = new Map( + (payload.allMediaItems as AllMediaItem[]).map((m) => [ + m.contentItem.id, + m, + ]), + ); + expect(mediaByItemId.get(contentItemId)?.isReported).toBe(true); + expect(mediaByItemId.get(threadItemId)?.isReported).toBe(false); + expect(mediaByItemId.get(contentItemId)?.contentItem.__typename).toBe( + 'ContentItem', + ); + expect(mediaByItemId.get(threadItemId)?.contentItem.__typename).toBe( + 'ThreadItem', + ); + + // Build reportedMedia from the items the server returned + const reportedMediaGql = payload.allMediaItems + .map((m: AllMediaItem) => { + const url = + (m.contentItem.data['image'] as { url?: string } | undefined) + ?.url ?? ''; + return ( + `{ id: ${jsonStringify(m.contentItem.id)} ` + + `typeId: ${jsonStringify(m.contentItem.type.id)} ` + + `url: ${jsonStringify(url)} ` + + `industryClassification: A1 ` + + `fileAnnotations: [] }` + ); + }) + .join('\n'); + + const submitRes = await harness.request.post('/api/v1/graphql').send({ + query: `mutation { + submitManualReviewDecision(input: { + queueId: ${jsonStringify(queueId)} + jobId: ${jsonStringify(jobId)} + lockToken: ${jsonStringify(lockToken)} + reportHistory: [] + relatedItemActions: [] + reportedItemDecisionComponents: [ + { + submitNcmecReport: { + incidentType: CHILD_PORNOGRAPHY + reportedMessages: [] + reportedMedia: [${reportedMediaGql}] + } + } + ] + }) { + ... on SubmitDecisionSuccessResponse { + success + } + } + }`, + }); + if (submitRes.status !== 200) { + throw new Error( + `submitManualReviewDecision failed: HTTP ${submitRes.status} — ${jsonStringify(submitRes.body)}`, + ); + } + expect(submitRes.body.errors).toBeUndefined(); + expect(submitRes.body.data?.submitManualReviewDecision?.success).toBe( + true, + ); + + // The IoC onRecordDecision handler fires asynchronously after the GQL + // response, so we poll until the expected CyberTip calls appear. + await waitFor('CyberTip /finish call', async () => { + const calls = fetchStub.calls + .filter((c) => c.url.includes('cybertip.org')) + .map((c) => c.url.replace(/^.*\/ispws/, '')); + if (!calls.includes('/finish')) return undefined; + return calls; + }); + + const cybertipPaths = fetchStub.calls + .filter((c) => c.url.includes('cybertip.org')) + .map((c) => c.url.replace(/^.*\/ispws/, '')); + expect(cybertipPaths.filter((p) => p === '/submit')).toHaveLength(1); + expect(cybertipPaths.filter((p) => p === '/finish')).toHaveLength(1); + + const newCalls = fetchStub.calls; + + // Both media items were downloaded from storage before upload + const downloadedUrls = newCalls + .filter((c) => c.method === 'get') + .map((c) => c.url); + expect(downloadedUrls).toContain(MEDIA_URL); + expect(downloadedUrls).toContain(THREAD_MEDIA_URL); + + const submitCall = newCalls.find( + (c) => c.url.endsWith('/ispws/submit') && typeof c.body === 'string', + ); + expect(submitCall?.headers?.Authorization).toMatch(/^Basic /); + expect(String(submitCall?.body)).toContain(''); + + // Each /fileinfo XML includes the originalFileName derived from the + // media URL, confirming the correct per-item data was sent to NCMEC + const fileinfoXmls = newCalls + .filter((c) => c.url.endsWith('/ispws/fileinfo')) + .map((c) => String(c.body)); + expect( + fileinfoXmls.some((xml) => xml.includes('ncmec-submit-decision.jpg')), + ).toBe(true); + expect( + fileinfoXmls.some((xml) => + xml.includes('ncmec-submit-decision-thread.jpg'), + ), + ).toBe(true); + + // Both media items are persisted in the ncmec_reports row + const reportRow = await waitFor( + `ncmec_reports row for user ${creatorUserId}`, + async () => + harness!.deps.KyselyPg.selectFrom('ncmec_reporting.ncmec_reports') + .select(['report_id', 'is_test', 'reviewer_id', 'reported_media']) + .where('org_id', '=', orgId) + .where('user_id', '=', creatorUserId) + .executeTakeFirst(), + ); + + expect(reportRow.report_id).toBe(ncmecReportId); + expect(reportRow.reviewer_id).toBe(reviewerId); + expect(reportRow.is_test).toBe(process.env.NCMEC_ENV !== 'production'); + + const reportedMediaIds = ( + reportRow.reported_media as Array<{ id: string; typeId: string }> + ).map((m) => m.id); + expect(reportedMediaIds).toContain(contentItemId); + expect(reportedMediaIds).toContain(threadItemId); + } finally { + await contentTypeFixture.cleanup(); + await threadTypeFixture.cleanup(); + } + }, 60_000); +}); diff --git a/server/test/integ/ncmec-submission.integ.test.ts b/server/test/integ/ncmec-submission.integ.test.ts index fc797948b..b979fcf8f 100644 --- a/server/test/integ/ncmec-submission.integ.test.ts +++ b/server/test/integ/ncmec-submission.integ.test.ts @@ -1,5 +1,4 @@ import { uid } from 'uid'; -import { Headers } from 'undici'; import { NCMECFileAnnotation, @@ -8,96 +7,13 @@ import { NcmecReporting, type NCMECReportParams, } from '../../services/ncmecService/index.js'; -import { - type CoopRequestQuery, - type CoopResponse, - type FetchHTTP, - type HandleResponseBody, -} from '../../services/networkingService/index.js'; import createOrg from '../fixtureHelpers/createOrg.js'; +import { makeStubFetchHTTP } from '../fixtureHelpers/makeStubFetchHTTP.js'; import { makeTransactionalTestWithFixture } from '../harness/transactionalTest.js'; const MEDIA_URL = 'https://cdn.example/sample.jpg'; const PRESERVATION_URL = 'https://preserve.example/req'; -/** Shape of one recorded outgoing fetchHTTP call. */ -type RecordedCall = { - url: string; - method: string; - body: unknown; - headers?: Record>; -}; - -/** Records every outgoing fetchHTTP call and returns canned CyberTip - * responses. */ -function makeStubFetchHTTP( - reportId: string, - fileId: string, -): { - fetchHTTP: FetchHTTP; - calls: RecordedCall[]; -} { - const calls: RecordedCall[] = []; - const ok = (body: unknown): CoopResponse => - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- the stub returns a canned body through a slot typed by the caller's T. - ({ - status: 200, - ok: true, - headers: new Headers(), - body, - }) as CoopResponse; - const fetchHTTP: FetchHTTP = async ( - query: CoopRequestQuery, - ): Promise> => { - const { url, method, body, headers } = query; - // eslint-disable-next-line functional/immutable-data -- request recorder mutates by design - calls.push({ url, method, body, headers }); - - // media download for #upload - if (method === 'get') { - const stream = new ReadableStream({ - start(ctr) { - ctr.enqueue(new TextEncoder().encode('fake-media-bytes')); - ctr.close(); - }, - }); - return ok(stream); - } - // NCMEC CyberTip protocol — every XML endpoint returns responseCode=0. - // /submit, /upload, /fileinfo use `reportResponse`; /finish uses - // `reportDoneResponse`. - if ( - url.endsWith('/ispws/submit') || - url.endsWith('/ispws/upload') || - url.endsWith('/ispws/fileinfo') - ) { - const isSubmit = url.endsWith('/ispws/submit'); - const isUpload = url.endsWith('/ispws/upload'); - return ok({ - reportResponse: { - responseCode: { _text: '0' }, - ...(isSubmit ? { reportId: { _text: reportId } } : {}), - ...(isUpload ? { fileId: { _text: fileId } } : {}), - }, - }); - } - if (url.endsWith('/ispws/finish')) { - return ok({ - reportDoneResponse: { - responseCode: { _text: '0' }, - reportId: { _text: reportId }, - files: [{ fileId: { _text: fileId } }], - }, - }); - } - if (url === PRESERVATION_URL) { - return ok(undefined); - } - throw new Error(`stub fetchHTTP: unexpected request ${method} ${url}`); - }; - return { fetchHTTP, calls }; -} - describe('NCMEC submitReport (integration)', () => { const testWithFixture = makeTransactionalTestWithFixture(async ({ deps }) => { const orgId = uid(); @@ -134,7 +50,9 @@ describe('NCMEC submitReport (integration)', () => { minMediaToReview: null, }); - const stub = makeStubFetchHTTP(reportId, fileId); + const stub = makeStubFetchHTTP(reportId, fileId, { + preservationUrl: PRESERVATION_URL, + }); const ncmecReporting = new NcmecReporting( deps.KyselyPg, deps.KyselyPgReadReplica, diff --git a/server/test/integ/setupIntegrationServer.ts b/server/test/integ/setupIntegrationServer.ts index fcf887546..c27937269 100644 --- a/server/test/integ/setupIntegrationServer.ts +++ b/server/test/integ/setupIntegrationServer.ts @@ -7,6 +7,7 @@ * via `npm run db:update`. */ +import passport from 'passport'; import * as superTest from 'supertest'; import getBottle, { type Dependencies } from '../../iocContainer/index.js'; @@ -18,8 +19,38 @@ export type IntegrationServer = { shutdown: () => Promise; }; -export async function makeIntegrationServer(): Promise { +export type MakeIntegrationServerOptions = { + /** A hash of mocked dependencies to replace in the bottle */ + mockedDeps?: Partial; +}; + +export async function makeIntegrationServer( + opts: MakeIntegrationServerOptions = {}, +): Promise { + // passport keeps its state globally so we need to reset it each time we make a new server + // there is no public API to reset serializers/deserializers so we resort to clearing them. + type PassportInternals = { + _serializers: Array; + _deserializers: Array; + _strategies: Record; + }; + const passportInternals = passport as unknown as PassportInternals; + // eslint-disable-next-line functional/immutable-data + passportInternals._serializers = []; + // eslint-disable-next-line functional/immutable-data + passportInternals._deserializers = []; + for (const key of Object.keys(passportInternals._strategies)) { + if (key !== 'session') { + passport.unuse(key); + } + } + const bottle = await getBottle(); + if (opts.mockedDeps != null) { + for (const [name, value] of Object.entries(opts.mockedDeps)) { + bottle.factory(name as keyof Dependencies, () => value); + } + } const deps = bottle.container as Dependencies; const { app, shutdown: shutdownServer } = await makeServer(deps);