diff --git a/.changeset/nip56-report-ingestion-gaps.md b/.changeset/nip56-report-ingestion-gaps.md new file mode 100644 index 00000000..4c93697f --- /dev/null +++ b/.changeset/nip56-report-ingestion-gaps.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +fix(nip56): skip targetless report rows, record every p/e target, batch report inserts in one transaction, and warm the WoT graph at boot instead of blocking the first report on a cold-start rebuild diff --git a/CONFIGURATION.md b/CONFIGURATION.md index ec492738..6ee8fcb6 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -215,7 +215,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip50.enabled | Enable or disable NIP-50 full-text search. Defaults to false. When enabled, clients can include a `search` field in REQ filters to perform text queries against event content. Requires the GIN full-text index migration. | | nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). **Note:** The GIN index migration is built with the `simple` configuration. If you change this value, you must manually rebuild the index: `DROP INDEX CONCURRENTLY events_content_fts_idx; CREATE INDEX CONCURRENTLY events_content_fts_idx ON events USING gin (to_tsvector('', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. | | nip50.maxQueryLength | Maximum length of the search query string. Queries exceeding this are truncated. Defaults to 256. | -| nip56.enabled | Enable NIP-56 content reporting. When true, kind-1984 report events are stored and scored by the reporter's WoT distance from `wot.seedPubkey`. Defaults to false. | +| nip56.enabled | Enable NIP-56 content reporting. When true, kind-1984 report events are stored and scored by the reporter's WoT distance from `wot.seedPubkey`. Defaults to false. If `wot.enabled` is false, every non-moderator report is still stored, but its WoT distance is always undefined, so it scores weight 0 and never becomes actionable — this matches the "record, never act" design, but is easy to misread as a misconfiguration. `reports` rows have no automatic retention/pruning: they can outlive the kind-1984 event that produced them (which is itself subject to normal event retention/deletion) and accumulate indefinitely; operators who want bounded growth need to prune the table themselves. | | nip56.trustedModerators | Pubkeys (hex) whose reports are always maximum-weight and actionable, regardless of WoT distance. Reports from any other pubkey are stored and weighted, but never trigger automatic actions on their own. Defaults to []. | | nip66.dnsCacheTtlSeconds | DNS cache TTL in seconds for repeated probe lookups of the same hostname. Defaults to 300. | | nip66.enabled | Enable NIP-66 relay monitoring. When true, starts a `relay-monitor` cluster worker that probes targets on an interval and stores the latest snapshot in Redis. Defaults to false. | diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index b119ebb0..8368444e 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -6,12 +6,12 @@ import { DBEvent, Event } from './event' import { CreateInviteCodeOptions, InviteCode } from './invite-code' import { Invoice } from './invoice' import { Nip05Verification } from './nip05' +import { NotificationOutboxMessage, NotificationOutboxPayload } from './notification-outbox' import { NotificationDeliveryLogEntry, NotificationDeliveryStatus, OperatorNotificationChannelType, } from './operator-notifications' -import { NotificationOutboxMessage, NotificationOutboxPayload } from './notification-outbox' import { Report } from './report' import { EventKindsRange } from './settings' import { SubscriptionFilter } from './subscription' @@ -93,6 +93,8 @@ export interface IDvmJobRepository { export interface IReportRepository { create(report: Omit): Promise + /** Inserts every row in a single transaction, so a mid-batch failure leaves none of them. */ + createMany(reports: Omit[]): Promise findByEventId(eventId: EventId): Promise findActionable(limit?: number): Promise } diff --git a/src/@types/services.ts b/src/@types/services.ts index bb7c124b..652b453e 100644 --- a/src/@types/services.ts +++ b/src/@types/services.ts @@ -1,5 +1,5 @@ -import { Invoice } from './invoice' import { Pubkey } from './base' +import { Invoice } from './invoice' import { NotificationOutboxPayload } from './notification-outbox' export interface IMaintenanceService { @@ -17,6 +17,12 @@ export interface IWotGraphService { isTrusted(pubkey: Pubkey): Promise /** Applies a pubkey's current NIP-02 follow list to the graph. */ updateFollowList(pubkey: Pubkey, follows: Pubkey[]): Promise + /** + * Fire-and-forget: kicks off the initial graph build without waiting on it, + * so the first real getDistance() call after startup doesn't have to pay + * for a cold-start rebuild itself. + */ + warmUp(): void } export interface IPaymentsService { @@ -35,11 +41,7 @@ export interface NotificationDispatchContext { } export interface INotificationDispatcher { - dispatch( - eventType: string, - payload: NotificationOutboxPayload, - context?: NotificationDispatchContext, - ): Promise + dispatch(eventType: string, payload: NotificationOutboxPayload, context?: NotificationDispatchContext): Promise } export interface IOperatorNotificationService extends INotificationDispatcher { diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 8e8b186a..b11f3339 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -1,4 +1,5 @@ import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters' +import { IncomingMessage, MessageType } from '../@types/messages' import { IDvmJobRepository, IEventRepository, @@ -7,21 +8,20 @@ import { IReportRepository, IUserRepository, } from '../@types/repositories' -import { IncomingMessage, MessageType } from '../@types/messages' -import { createSettings } from './settings-factory' +import { RedisAdapter } from '../adapters/redis-adapter' +import { getCacheClient } from '../cache/client' import { AuthMessageHandler } from '../handlers/auth-message-handler' import { CountMessageHandler } from '../handlers/count-message-handler' import { EventMessageHandler } from '../handlers/event-message-handler' -import { eventStrategyFactory } from './event-strategy-factory' -import { getCacheClient } from '../cache/client' -import { RedisAdapter } from '../adapters/redis-adapter' -import { rateLimiterFactory } from './rate-limiter-factory' import { SubscribeMessageHandler } from '../handlers/subscribe-message-handler' import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handler' +import { eventStrategyFactory } from './event-strategy-factory' +import { rateLimiterFactory } from './rate-limiter-factory' +import { createSettings } from './settings-factory' import { wotGraphServiceFactory } from './wot-graph-service-factory' let cacheAdapter: ICacheAdapter | undefined = undefined -const getCache = (): ICacheAdapter => { +export const getCache = (): ICacheAdapter => { if (!cacheAdapter) { cacheAdapter = new RedisAdapter(getCacheClient()) } diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index 828b1991..6aa8652c 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -1,21 +1,22 @@ -import { is, path, pathSatisfies } from 'ramda' import http from 'http' import process from 'process' +import { is, path, pathSatisfies } from 'ramda' import { WebSocketServer } from 'ws' - -import { getMasterDbClient, getReadReplicaDbClient } from '../database/client' +import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter' import { AppWorker } from '../app/worker' -import { createLogger } from './logger-factory' +import { getMasterDbClient, getReadReplicaDbClient } from '../database/client' import { createSettings } from '../factories/settings-factory' -import { createWebApp } from './web-app-factory' import { DvmJobRepository } from '../repositories/dvm-job-repository' import { EventRepository } from '../repositories/event-repository' import { InviteCodeRepository } from '../repositories/invite-code-repository' import { Nip05VerificationRepository } from '../repositories/nip05-verification-repository' import { ReportRepository } from '../repositories/report-repository' import { UserRepository } from '../repositories/user-repository' +import { createLogger } from './logger-factory' +import { getCache } from './message-handler-factory' +import { createWebApp } from './web-app-factory' import { webSocketAdapterFactory } from './websocket-adapter-factory' -import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter' +import { wotGraphServiceFactory } from './wot-graph-service-factory' const logger = createLogger('worker-factory') @@ -29,6 +30,13 @@ export const workerFactory = (): AppWorker => { const dvmJobRepository = new DvmJobRepository(dbClient) const reportRepository = new ReportRepository(dbClient) + // Constructs the WoT graph singleton (and starts warming it up, if enabled) + // right at worker boot -- before this call, the singleton was only ever + // created lazily inside per-event handler wiring, so the very first + // EVENT/report needing a WoT distance was also the thing paying for the + // cold-start rebuild the warm-up was meant to avoid. + wotGraphServiceFactory(getCache(), eventRepository, createSettings) + const settings = createSettings() const app = createWebApp() diff --git a/src/factories/wot-graph-service-factory.ts b/src/factories/wot-graph-service-factory.ts index 840d4b04..e93fdffb 100644 --- a/src/factories/wot-graph-service-factory.ts +++ b/src/factories/wot-graph-service-factory.ts @@ -13,6 +13,11 @@ export const wotGraphServiceFactory = ( ): IWotGraphService => { if (!instance) { instance = new WotGraphService(cache, eventRepository, settings) + // Kick off the graph build as soon as the singleton exists (effectively + // at worker boot, since this factory runs while wiring up the message + // handler/event strategy chains) instead of waiting for the first + // report or PoW check to pay for a cold-start rebuild. + instance.warmUp() } return instance diff --git a/src/handlers/event-strategies/report-event-strategy.ts b/src/handlers/event-strategies/report-event-strategy.ts index 9905c47d..63c6300d 100644 --- a/src/handlers/event-strategies/report-event-strategy.ts +++ b/src/handlers/event-strategies/report-event-strategy.ts @@ -1,14 +1,15 @@ -import { createEventCommandResult } from '../../telemetry/event-metrics' -import { createLogger } from '../../factories/logger-factory' -import { calculateReportWeight } from '../../utils/report-scoring' +import { IWebSocketAdapter } from '../../@types/adapters' import { Event } from '../../@types/event' -import { extractReportTargets } from '../../utils/nip56' -import { IEventRepository, IReportRepository } from '../../@types/repositories' import { IEventStrategy } from '../../@types/message-handlers' -import { IWebSocketAdapter } from '../../@types/adapters' +import { Report } from '../../@types/report' +import { IEventRepository, IReportRepository } from '../../@types/repositories' import { IWotGraphService } from '../../@types/services' import { Settings } from '../../@types/settings' import { WebSocketAdapterEvent } from '../../constants/adapter' +import { createLogger } from '../../factories/logger-factory' +import { createEventCommandResult } from '../../telemetry/event-metrics' +import { extractReportTargets } from '../../utils/nip56' +import { calculateReportWeight } from '../../utils/report-scoring' const logger = createLogger('report-event-strategy') @@ -51,21 +52,32 @@ export class ReportEventStrategy implements IEventStrategy> const distance = isTrustedModerator ? undefined : await this.wotGraphService.getDistance(event.pubkey) const weight = calculateReportWeight(distance, isTrustedModerator) + const reports: Omit[] = [] for (const target of extractReportTargets(event.tags)) { - const hasValidTarget = target.reportedPubkey !== null || target.reportedEventId !== null + // A report naming no pubkey/event has nothing to act on -- store the + // event itself (already done above) but skip the row instead of + // persisting a dead reports entry. + if (target.reportedPubkey === null && target.reportedEventId === null) { + continue + } - await this.reportRepository.create({ + reports.push({ eventId: event.id, reporterPubkey: event.pubkey, reportedPubkey: target.reportedPubkey, reportedEventId: target.reportedEventId, reportType: target.reportType, weight, - // A moderator report with no valid target has nothing to act on -- - // never mark it actionable regardless of who sent it. - actionable: isTrustedModerator && hasValidTarget, + actionable: isTrustedModerator, }) } + + if (reports.length) { + // One transaction for every target on this event -- a report event + // producing more than one row (see extractReportTargets) shouldn't be + // able to leave a partial set behind on a mid-batch failure. + await this.reportRepository.createMany(reports) + } } catch (error) { // Report scoring/recording is best-effort: the report event itself is // already stored and broadcast correctly, so a failure here must not diff --git a/src/repositories/report-repository.ts b/src/repositories/report-repository.ts index a7416ec7..bdd01fe9 100644 --- a/src/repositories/report-repository.ts +++ b/src/repositories/report-repository.ts @@ -52,6 +52,25 @@ export class ReportRepository implements IReportRepository { return fromDBReport({ ...row, id: inserted.id }) } + public async createMany( + reports: Omit[], + client: DatabaseClient = this.dbClient, + ): Promise { + if (!reports.length) { + return [] + } + + logger('create %d reports in a single transaction', reports.length) + + return client.transaction(async (trx) => { + const created: Report[] = [] + for (const report of reports) { + created.push(await this.create(report, trx)) + } + return created + }) + } + public async findByEventId(eventId: EventId, client: DatabaseClient = this.dbClient): Promise { logger('find reports for event %s', eventId) diff --git a/src/services/wot-graph-service.ts b/src/services/wot-graph-service.ts index 8dc9cf25..e4d84ab1 100644 --- a/src/services/wot-graph-service.ts +++ b/src/services/wot-graph-service.ts @@ -1,10 +1,10 @@ -import { createLogger } from '../factories/logger-factory' -import { EventKinds, EventTags } from '../constants/base' import { ICacheAdapter } from '../@types/adapters' +import { Pubkey, Tag } from '../@types/base' import { IEventRepository } from '../@types/repositories' import { IWotGraphService } from '../@types/services' -import { Pubkey, Tag } from '../@types/base' import { Settings } from '../@types/settings' +import { EventKinds, EventTags } from '../constants/base' +import { createLogger } from '../factories/logger-factory' import { toNostrEvent } from '../utils/event' const logger = createLogger('wot-graph-service') @@ -37,6 +37,19 @@ export class WotGraphService implements IWotGraphService { return this.ready } + public warmUp(): void { + // Do NOT call ensureBuilt() when WoT is disabled: rebuild()'s + // disabled-graph branch marks `ready = true` permanently, and if an + // operator later hot-enables WoT at runtime, getDistance() would see + // `ready` already true and skip rebuilding, leaving the graph + // permanently empty until the worker restarts. Only warm up when + // there's an actual graph to build. + if (!this.settings().wot?.enabled) { + return + } + void this.ensureBuilt() + } + public async getDistance(pubkey: Pubkey): Promise { const wot = this.settings().wot if (!wot?.enabled || !wot.seedPubkey) { diff --git a/src/utils/nip56.ts b/src/utils/nip56.ts index 6bee9c78..716fdfb7 100644 --- a/src/utils/nip56.ts +++ b/src/utils/nip56.ts @@ -1,7 +1,10 @@ import { EventId, Pubkey, Tag } from '../@types/base' -import { EventKinds, EventTags } from '../constants/base' import { Event } from '../@types/event' import { ReportType } from '../@types/report' +import { EventKinds, EventTags } from '../constants/base' +import { createLogger } from '../factories/logger-factory' + +const logger = createLogger('nip56') export const isReportEvent = (event: Event): boolean => event.kind === EventKinds.REPORT @@ -25,41 +28,75 @@ export interface ReportTarget { reportType: ReportType } +const targetTypeOf = (tag: Tag): ReportType => (isValidReportType(tag[2]) ? tag[2] : ReportType.OTHER) + +// A single report event can legitimately carry more than one p/e tag (e.g. +// a moderator batch-reporting a raid of spam accounts in one event); cap how +// many targets one event can produce so a pathological event can't fan out +// into an unbounded number of rows, while staying well above any realistic +// legitimate batch so a genuine report isn't silently truncated. +const MAX_TARGETS_PER_EVENT = 100 + // NIP-56: the report type is the 3rd element of the tag identifying what's // being reported. A p tag and an e tag are separate claims (report this // pubkey, report this event) that happen to travel in the same event -- when -// they share a type (the common case: reporting one piece of content and its -// author for the same reason) they collapse into a single target; when their -// types disagree, each keeps its own type as its own target rather than one -// silently overwriting the other's. +// the *first* p tag and *first* e tag share a type (the common case: +// reporting one piece of content and its author for the same reason) they +// collapse into a single target; when their types disagree, each keeps its +// own type as its own target rather than one silently overwriting the +// other's. Any additional p/e tags beyond the first pair are independent, +// single-field targets in their own right. export const extractReportTargets = (tags: Tag[]): ReportTarget[] => { - const pTag = tags.find((tag) => tag[0] === EventTags.Pubkey && tag.length >= 2 && isValidHex64(tag[1])) - const eTag = tags.find((tag) => tag[0] === EventTags.Event && tag.length >= 2 && isValidHex64(tag[1])) - - const pType = isValidReportType(pTag?.[2]) ? pTag![2] : undefined - const eType = isValidReportType(eTag?.[2]) ? eTag![2] : undefined + const pTags = tags.filter((tag) => tag[0] === EventTags.Pubkey && tag.length >= 2 && isValidHex64(tag[1])) + const eTags = tags.filter((tag) => tag[0] === EventTags.Event && tag.length >= 2 && isValidHex64(tag[1])) - if (!pTag && !eTag) { + if (!pTags.length && !eTags.length) { return [{ reportedPubkey: null, reportedEventId: null, reportType: ReportType.OTHER }] } + const [pTag, ...extraPTags] = pTags + const [eTag, ...extraETags] = eTags + + const targets: ReportTarget[] = [] + if (pTag && eTag) { + const pType = isValidReportType(pTag[2]) ? pTag[2] : undefined + const eType = isValidReportType(eTag[2]) ? eTag[2] : undefined + // Only a genuine disagreement (both sides carry an explicit, different // type) splits into two targets; one side simply omitting a type isn't a // conflict, so it falls back to whichever side did specify one. if (pType !== undefined && eType !== undefined && pType !== eType) { - return [ - { reportedPubkey: null, reportedEventId: eTag[1], reportType: eType }, - { reportedPubkey: pTag[1], reportedEventId: null, reportType: pType }, - ] + targets.push({ reportedPubkey: null, reportedEventId: eTag[1], reportType: eType }) + targets.push({ reportedPubkey: pTag[1], reportedEventId: null, reportType: pType }) + } else { + targets.push({ + reportedPubkey: pTag[1], + reportedEventId: eTag[1], + reportType: eType ?? pType ?? ReportType.OTHER, + }) } + } else if (eTag) { + targets.push({ reportedPubkey: null, reportedEventId: eTag[1], reportType: targetTypeOf(eTag) }) + } else if (pTag) { + targets.push({ reportedPubkey: pTag[1], reportedEventId: null, reportType: targetTypeOf(pTag) }) + } - return [{ reportedPubkey: pTag[1], reportedEventId: eTag[1], reportType: eType ?? pType ?? ReportType.OTHER }] + for (const tag of extraPTags) { + targets.push({ reportedPubkey: tag[1], reportedEventId: null, reportType: targetTypeOf(tag) }) + } + for (const tag of extraETags) { + targets.push({ reportedPubkey: null, reportedEventId: tag[1], reportType: targetTypeOf(tag) }) } - if (eTag) { - return [{ reportedPubkey: null, reportedEventId: eTag[1], reportType: eType ?? ReportType.OTHER }] + if (targets.length > MAX_TARGETS_PER_EVENT) { + logger.error( + 'report event carries %d targets, exceeding the %d-target cap; dropping the rest', + targets.length, + MAX_TARGETS_PER_EVENT, + ) + return targets.slice(0, MAX_TARGETS_PER_EVENT) } - return [{ reportedPubkey: pTag![1], reportedEventId: null, reportType: pType ?? ReportType.OTHER }] + return targets } diff --git a/test/unit/factories/worker-factory.spec.ts b/test/unit/factories/worker-factory.spec.ts index 89138dbd..5a32096c 100644 --- a/test/unit/factories/worker-factory.spec.ts +++ b/test/unit/factories/worker-factory.spec.ts @@ -1,24 +1,30 @@ import { expect } from 'chai' import Sinon from 'sinon' - -import * as databaseClientModule from '../../../src/database/client' - import { AppWorker } from '../../../src/app/worker' -import { SettingsStatic } from '../../../src/utils/settings' +import * as cacheClientModule from '../../../src/cache/client' +import * as databaseClientModule from '../../../src/database/client' import { workerFactory } from '../../../src/factories/worker-factory' +import { SettingsStatic } from '../../../src/utils/settings' describe('workerFactory', () => { let createSettingsStub: Sinon.SinonStub let getMasterDbClientStub: Sinon.SinonStub let getReadReplicaDbClientStub: Sinon.SinonStub + let getCacheClientStub: Sinon.SinonStub beforeEach(() => { createSettingsStub = Sinon.stub(SettingsStatic, 'createSettings') getMasterDbClientStub = Sinon.stub(databaseClientModule, 'getMasterDbClient') getReadReplicaDbClientStub = Sinon.stub(databaseClientModule, 'getReadReplicaDbClient') + // workerFactory() now constructs the WoT graph singleton at boot (via + // getCache()), which would otherwise build a real Redis client here. + const fakeRedisClient: any = { isOpen: true } + fakeRedisClient.on = Sinon.stub().returns(fakeRedisClient) + getCacheClientStub = Sinon.stub(cacheClientModule, 'getCacheClient').returns(fakeRedisClient) }) afterEach(() => { + getCacheClientStub.restore() getReadReplicaDbClientStub.restore() getMasterDbClientStub.restore() createSettingsStub.restore() diff --git a/test/unit/handlers/event-strategies/report-event-strategy.spec.ts b/test/unit/handlers/event-strategies/report-event-strategy.spec.ts index 44533fdf..c0999d5b 100644 --- a/test/unit/handlers/event-strategies/report-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/report-event-strategy.spec.ts @@ -6,16 +6,16 @@ chai.use(chaiAsPromised) const { expect } = chai +import { IWebSocketAdapter } from '../../../../src/@types/adapters' import { Event } from '../../../../src/@types/event' -import { IEventRepository, IReportRepository } from '../../../../src/@types/repositories' import { IEventStrategy } from '../../../../src/@types/message-handlers' -import { IWebSocketAdapter } from '../../../../src/@types/adapters' -import { IWotGraphService } from '../../../../src/@types/services' import { MessageType } from '../../../../src/@types/messages' -import { ReportEventStrategy } from '../../../../src/handlers/event-strategies/report-event-strategy' import { ReportType } from '../../../../src/@types/report' +import { IEventRepository, IReportRepository } from '../../../../src/@types/repositories' +import { IWotGraphService } from '../../../../src/@types/services' import { Settings } from '../../../../src/@types/settings' import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' +import { ReportEventStrategy } from '../../../../src/handlers/event-strategies/report-event-strategy' describe('ReportEventStrategy', () => { const reporterPubkey = '2'.repeat(64) @@ -37,7 +37,7 @@ describe('ReportEventStrategy', () => { let webSocketEmitStub: Sinon.SinonStub let eventRepositoryCreateStub: Sinon.SinonStub - let reportRepositoryCreateStub: Sinon.SinonStub + let reportRepositoryCreateManyStub: Sinon.SinonStub let getDistanceStub: Sinon.SinonStub let strategy: IEventStrategy> @@ -57,9 +57,9 @@ describe('ReportEventStrategy', () => { create: eventRepositoryCreateStub, } as any - reportRepositoryCreateStub = sandbox.stub() + reportRepositoryCreateManyStub = sandbox.stub() reportRepository = { - create: reportRepositoryCreateStub, + createMany: reportRepositoryCreateManyStub, } as any getDistanceStub = sandbox.stub() @@ -79,7 +79,7 @@ describe('ReportEventStrategy', () => { describe('execute', () => { it('creates the event', async () => { eventRepositoryCreateStub.resolves(1) - reportRepositoryCreateStub.resolves({}) + reportRepositoryCreateManyStub.resolves([{}]) getDistanceStub.resolves(1) await strategy.execute(event) @@ -89,7 +89,7 @@ describe('ReportEventStrategy', () => { it('broadcasts the event when newly created', async () => { eventRepositoryCreateStub.resolves(1) - reportRepositoryCreateStub.resolves({}) + reportRepositoryCreateManyStub.resolves([{}]) getDistanceStub.resolves(1) await strategy.execute(event) @@ -114,96 +114,103 @@ describe('ReportEventStrategy', () => { true, 'duplicate:', ]) - expect(reportRepositoryCreateStub).not.to.have.been.called + expect(reportRepositoryCreateManyStub).not.to.have.been.called }) it('records a report with full weight for a direct follow (distance 1)', async () => { eventRepositoryCreateStub.resolves(1) - reportRepositoryCreateStub.resolves({}) + reportRepositoryCreateManyStub.resolves([{}]) getDistanceStub.resolves(1) await strategy.execute(event) - expect(reportRepositoryCreateStub).to.have.been.calledOnceWithExactly({ - eventId: 'event-id', - reporterPubkey, - reportedPubkey, - reportedEventId: null, - reportType: ReportType.SPAM, - weight: 1, - actionable: false, - }) + expect(reportRepositoryCreateManyStub).to.have.been.calledOnceWithExactly([ + { + eventId: 'event-id', + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.SPAM, + weight: 1, + actionable: false, + }, + ]) }) it('records a report with zero weight for a reporter outside the trust graph', async () => { eventRepositoryCreateStub.resolves(1) - reportRepositoryCreateStub.resolves({}) + reportRepositoryCreateManyStub.resolves([{}]) getDistanceStub.resolves(undefined) await strategy.execute(event) - expect(reportRepositoryCreateStub).to.have.been.calledOnceWithExactly({ - eventId: 'event-id', - reporterPubkey, - reportedPubkey, - reportedEventId: null, - reportType: ReportType.SPAM, - weight: 0, - actionable: false, - }) + expect(reportRepositoryCreateManyStub).to.have.been.calledOnceWithExactly([ + { + eventId: 'event-id', + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.SPAM, + weight: 0, + actionable: false, + }, + ]) }) it('records an actionable, max-weight report from a trusted moderator regardless of distance', async () => { settings = () => ({ nip56: { enabled: true, trustedModerators: [reporterPubkey] } }) as any strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) eventRepositoryCreateStub.resolves(1) - reportRepositoryCreateStub.resolves({}) + reportRepositoryCreateManyStub.resolves([{}]) await strategy.execute(event) - expect(reportRepositoryCreateStub).to.have.been.calledOnceWithExactly({ - eventId: 'event-id', - reporterPubkey, - reportedPubkey, - reportedEventId: null, - reportType: ReportType.SPAM, - weight: 1, - actionable: true, - }) + expect(reportRepositoryCreateManyStub).to.have.been.calledOnceWithExactly([ + { + eventId: 'event-id', + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.SPAM, + weight: 1, + actionable: true, + }, + ]) }) it('does not consult the WoT graph for a trusted moderator', async () => { settings = () => ({ nip56: { enabled: true, trustedModerators: [reporterPubkey] } }) as any strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) eventRepositoryCreateStub.resolves(1) - reportRepositoryCreateStub.resolves({}) + reportRepositoryCreateManyStub.resolves([{}]) await strategy.execute(event) expect(getDistanceStub).not.to.have.been.called }) - it('does not mark a moderator report actionable when it has no valid target', async () => { + it('does not record any report when the event has no valid target', async () => { + const noTargetEvent: Event = { ...event, tags: [] } as any + eventRepositoryCreateStub.resolves(1) + getDistanceStub.resolves(1) + + await strategy.execute(noTargetEvent) + + expect(reportRepositoryCreateManyStub).not.to.have.been.called + }) + + it('does not record any report for a moderator event with no valid target', async () => { const noTargetEvent: Event = { ...event, tags: [] } as any settings = () => ({ nip56: { enabled: true, trustedModerators: [reporterPubkey] } }) as any strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) eventRepositoryCreateStub.resolves(1) - reportRepositoryCreateStub.resolves({}) await strategy.execute(noTargetEvent) - expect(reportRepositoryCreateStub).to.have.been.calledOnceWithExactly({ - eventId: 'event-id', - reporterPubkey, - reportedPubkey: null, - reportedEventId: null, - reportType: ReportType.OTHER, - weight: 1, - actionable: false, - }) + expect(reportRepositoryCreateManyStub).not.to.have.been.called }) - it('records one row per target when p and e tags carry different report types', async () => { + it('records one row per target, in a single batch, when p and e tags carry different report types', async () => { const mixedEvent: Event = { ...event, tags: [ @@ -212,30 +219,31 @@ describe('ReportEventStrategy', () => { ], } as any eventRepositoryCreateStub.resolves(1) - reportRepositoryCreateStub.resolves({}) + reportRepositoryCreateManyStub.resolves([{}, {}]) getDistanceStub.resolves(1) await strategy.execute(mixedEvent) - expect(reportRepositoryCreateStub).to.have.been.calledTwice - expect(reportRepositoryCreateStub.firstCall).to.have.been.calledWithExactly({ - eventId: 'event-id', - reporterPubkey, - reportedPubkey: null, - reportedEventId, - reportType: ReportType.NUDITY, - weight: 1, - actionable: false, - }) - expect(reportRepositoryCreateStub.secondCall).to.have.been.calledWithExactly({ - eventId: 'event-id', - reporterPubkey, - reportedPubkey, - reportedEventId: null, - reportType: ReportType.IMPERSONATION, - weight: 1, - actionable: false, - }) + expect(reportRepositoryCreateManyStub).to.have.been.calledOnceWithExactly([ + { + eventId: 'event-id', + reporterPubkey, + reportedPubkey: null, + reportedEventId, + reportType: ReportType.NUDITY, + weight: 1, + actionable: false, + }, + { + eventId: 'event-id', + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.IMPERSONATION, + weight: 1, + actionable: false, + }, + ]) }) it('stores the event but does not record a report when nip56 is disabled', async () => { @@ -247,14 +255,14 @@ describe('ReportEventStrategy', () => { expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Broadcast, event) - expect(reportRepositoryCreateStub).not.to.have.been.called + expect(reportRepositoryCreateManyStub).not.to.have.been.called expect(getDistanceStub).not.to.have.been.called }) it('does not reject the event when report recording fails', async () => { eventRepositoryCreateStub.resolves(1) getDistanceStub.resolves(1) - reportRepositoryCreateStub.rejects(new Error('db unavailable')) + reportRepositoryCreateManyStub.rejects(new Error('db unavailable')) await expect(strategy.execute(event)).to.eventually.be.fulfilled @@ -272,7 +280,7 @@ describe('ReportEventStrategy', () => { await expect(strategy.execute(event)).to.eventually.be.rejectedWith(error) - expect(reportRepositoryCreateStub).not.to.have.been.called + expect(reportRepositoryCreateManyStub).not.to.have.been.called }) }) }) diff --git a/test/unit/repositories/report-repository.spec.ts b/test/unit/repositories/report-repository.spec.ts index ad36bfd9..33a34562 100644 --- a/test/unit/repositories/report-repository.spec.ts +++ b/test/unit/repositories/report-repository.spec.ts @@ -4,8 +4,8 @@ import * as sinon from 'sinon' import sinonChai from 'sinon-chai' import { DatabaseClient } from '../../../src/@types/base' -import { ReportRepository } from '../../../src/repositories/report-repository' import { ReportType } from '../../../src/@types/report' +import { ReportRepository } from '../../../src/repositories/report-repository' chai.use(sinonChai) chai.use(chaiAsPromised) @@ -149,6 +149,75 @@ describe('ReportRepository', () => { }) }) + describe('.createMany', () => { + it('returns an empty array without opening a transaction when given no reports', async () => { + const transactionStub = sandbox.stub() + const client = { transaction: transactionStub } as unknown as DatabaseClient + + const result = await repository.createMany([], client) + + expect(result).to.deep.equal([]) + expect(transactionStub).not.to.have.been.called + }) + + it('inserts every report inside a single transaction', async () => { + const returningStub = sandbox.stub().resolves([{ id: 7 }]) + const insertStub = sandbox.stub().returns({ returning: returningStub }) + const trx = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + const transactionStub = sandbox.stub().callsFake(async (fn: (trx: DatabaseClient) => Promise) => fn(trx)) + const client = { transaction: transactionStub } as unknown as DatabaseClient + + const reports = [ + { + eventId, + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.SPAM, + weight: 1, + actionable: false, + }, + { + eventId, + reporterPubkey, + reportedPubkey: null, + reportedEventId, + reportType: ReportType.NUDITY, + weight: 0.5, + actionable: false, + }, + ] + + const result = await repository.createMany(reports, client) + + expect(transactionStub).to.have.been.calledOnce + expect(insertStub).to.have.been.calledTwice + expect(result).to.have.lengthOf(2) + }) + + it('propagates a failure from the transaction without inserting a partial set', async () => { + const transactionStub = sandbox.stub().rejects(new Error('constraint violation')) + const client = { transaction: transactionStub } as unknown as DatabaseClient + + await expect( + repository.createMany( + [ + { + eventId, + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.SPAM, + weight: 1, + actionable: false, + }, + ], + client, + ), + ).to.eventually.be.rejectedWith('constraint violation') + }) + }) + describe('.findByEventId', () => { it('returns an empty array when no reports are found', async () => { const client = sandbox.stub().returns({ diff --git a/test/unit/services/wot-graph-service.spec.ts b/test/unit/services/wot-graph-service.spec.ts index e68d5754..a0bb5ecc 100644 --- a/test/unit/services/wot-graph-service.spec.ts +++ b/test/unit/services/wot-graph-service.spec.ts @@ -8,11 +8,11 @@ chai.use(chaiAsPromised) const { expect } = chai -import { DBEvent } from '../../../src/@types/event' import { ICacheAdapter } from '../../../src/@types/adapters' +import { Tag } from '../../../src/@types/base' +import { DBEvent } from '../../../src/@types/event' import { IEventRepository } from '../../../src/@types/repositories' import { Settings } from '../../../src/@types/settings' -import { Tag } from '../../../src/@types/base' import { WotGraphService } from '../../../src/services/wot-graph-service' describe('WotGraphService', () => { @@ -110,6 +110,34 @@ describe('WotGraphService', () => { }) }) + describe('warmUp', () => { + it('does not block the caller', () => { + const wot = service() + expect(() => wot.warmUp()).to.not.throw() + expect(wot.isReady()).to.equal(false) + }) + + it('eventually completes a build without a getDistance call', async () => { + const wot = service() + wot.warmUp() + await new Promise((resolve) => setImmediate(resolve)) + expect(wot.isReady()).to.equal(true) + }) + + it('does nothing when wot is disabled, so a later hot-enable still triggers a real build', async () => { + settings.wot!.enabled = false + const wot = service() + + wot.warmUp() + await new Promise((resolve) => setImmediate(resolve)) + expect(wot.isReady()).to.equal(false) + + settings.wot!.enabled = true + await wot.getDistance('someone') + expect(wot.isReady()).to.equal(true) + }) + }) + describe('getDistance', () => { it('returns undefined when wot is disabled', async () => { settings.wot!.enabled = false diff --git a/test/unit/utils/nip56.spec.ts b/test/unit/utils/nip56.spec.ts index 1249f0c5..df32aa1d 100644 --- a/test/unit/utils/nip56.spec.ts +++ b/test/unit/utils/nip56.spec.ts @@ -1,8 +1,8 @@ import { expect } from 'chai' +import { Tag } from '../../../src/@types/base' import { Event } from '../../../src/@types/event' -import { extractReportTargets, isReportEvent } from '../../../src/utils/nip56' import { ReportType } from '../../../src/@types/report' -import { Tag } from '../../../src/@types/base' +import { extractReportTargets, isReportEvent } from '../../../src/utils/nip56' const baseEvent = (): Partial => ({ kind: 1984, @@ -131,5 +131,53 @@ describe('NIP-56', () => { { reportedPubkey: 'a'.repeat(64), reportedEventId: null, reportType: ReportType.SPAM }, ]) }) + + it('records a target for every additional p tag beyond the first', () => { + const tags = [ + ['p', 'a'.repeat(64), 'spam'], + ['p', 'c'.repeat(64), 'malware'], + ['p', 'd'.repeat(64)], + ] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: 'a'.repeat(64), reportedEventId: null, reportType: ReportType.SPAM }, + { reportedPubkey: 'c'.repeat(64), reportedEventId: null, reportType: ReportType.MALWARE }, + { reportedPubkey: 'd'.repeat(64), reportedEventId: null, reportType: ReportType.OTHER }, + ]) + }) + + it('records a target for every additional e tag beyond the first', () => { + const tags = [ + ['e', 'b'.repeat(64), 'nudity'], + ['e', 'f'.repeat(64), 'illegal'], + ] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: null, reportedEventId: 'b'.repeat(64), reportType: ReportType.NUDITY }, + { reportedPubkey: null, reportedEventId: 'f'.repeat(64), reportType: ReportType.ILLEGAL }, + ]) + }) + + it('combines a merged first p/e pair with additional independent targets', () => { + const tags = [ + ['p', 'a'.repeat(64), 'nudity'], + ['e', 'b'.repeat(64), 'nudity'], + ['p', 'c'.repeat(64), 'spam'], + ] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: 'a'.repeat(64), reportedEventId: 'b'.repeat(64), reportType: ReportType.NUDITY }, + { reportedPubkey: 'c'.repeat(64), reportedEventId: null, reportType: ReportType.SPAM }, + ]) + }) + + it('caps the number of targets a single event can produce', () => { + const tags = Array.from({ length: 105 }, (_, i) => ['p', i.toString(16).padStart(64, '0'), 'spam']) as Tag[] + const targets = extractReportTargets(tags) + expect(targets).to.have.lengthOf(100) + }) + + it('does not truncate a realistic moderator batch report', () => { + const tags = Array.from({ length: 21 }, (_, i) => ['p', i.toString(16).padStart(64, '0'), 'spam']) as Tag[] + const targets = extractReportTargets(tags) + expect(targets).to.have.lengthOf(21) + }) }) })