From bd568d4a3939692c4fd580226e3f2f2a17815b91 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Sun, 19 Apr 2026 19:29:56 +0530 Subject: [PATCH 1/6] test: add NIP-40 standalone expiration integration coverage --- test/integration/features/helpers.ts | 23 ++-- .../features/nip-40/nip-40.feature | 25 ++++ .../features/nip-40/nip-40.feature.ts | 122 ++++++++++++++++++ 3 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 test/integration/features/nip-40/nip-40.feature create mode 100644 test/integration/features/nip-40/nip-40.feature.ts diff --git a/test/integration/features/helpers.ts b/test/integration/features/helpers.ts index a401d3a8..4bd62d12 100644 --- a/test/integration/features/helpers.ts +++ b/test/integration/features/helpers.ts @@ -106,19 +106,14 @@ export async function waitForEOSE(ws: WebSocket, subscription: string): Promise< }) } -export async function sendEvent(ws: WebSocket, event: Event, successful = true) { - return new Promise((resolve, reject) => { +export async function publishEvent(ws: WebSocket, event: Event): Promise { + return new Promise((resolve, reject) => { const observable = streams.get(ws) as Observable const sub = observable.subscribe((message: OutgoingMessage) => { if (message[0] === MessageType.OK && message[1] === event.id) { - if (message[2] === successful) { - sub.unsubscribe() - resolve(message) - } else { - sub.unsubscribe() - reject(new Error(message[3])) - } + sub.unsubscribe() + resolve(message) } else if (message[0] === MessageType.NOTICE) { sub.unsubscribe() reject(new Error(message[1])) @@ -134,6 +129,16 @@ export async function sendEvent(ws: WebSocket, event: Event, successful = true) }) } +export async function sendEvent(ws: WebSocket, event: Event, successful = true) { + const result = await publishEvent(ws, event) + + if (result[2] !== successful) { + throw new Error(result[3]) + } + + return result +} + export async function waitForNextEvent(ws: WebSocket, subscription: string, content?: string): Promise { return new Promise((resolve, reject) => { const observable = streams.get(ws) as Observable diff --git a/test/integration/features/nip-40/nip-40.feature b/test/integration/features/nip-40/nip-40.feature new file mode 100644 index 00000000..509cd7dd --- /dev/null +++ b/test/integration/features/nip-40/nip-40.feature @@ -0,0 +1,25 @@ +@nip40 +@expiration +@standalone +Feature: NIP-40 Event expiration for standalone events + Scenario: Event with expiration tag in the past is not returned in queries + Given someone called Alice + And someone called Bob + When Alice sends a text_note event with content "already expired" and expiration in the past + And Bob subscribes to text_note events from Alice + Then Bob receives 0 text_note events and EOSE + + Scenario: Event with expiration tag in the future is returned normally + Given someone called Alice + And someone called Bob + When Alice sends a text_note event with content "not yet expired" and expiration in the future + And Bob subscribes to text_note events from Alice + Then Bob receives a text_note event from Alice with content "not yet expired" + + Scenario: Stored event is not returned to new subscribers after expiration time passes + Given someone called Alice + And someone called Bob + When Alice sends a text_note event with content "short lived" and expiration in 2 seconds + And Bob waits until Alice's last text_note event expires + And Bob subscribes to text_note events from Alice + Then Bob receives 0 text_note events and EOSE diff --git a/test/integration/features/nip-40/nip-40.feature.ts b/test/integration/features/nip-40/nip-40.feature.ts new file mode 100644 index 00000000..7ff79129 --- /dev/null +++ b/test/integration/features/nip-40/nip-40.feature.ts @@ -0,0 +1,122 @@ +import { Then, When, World } from '@cucumber/cucumber' +import { expect } from 'chai' +import WebSocket from 'ws' + +import { createEvent, createSubscription, publishEvent, waitForEventCount } from '../helpers' +import { Event, ExpiringEvent } from '../../../../src/@types/event' +import { EventExpirationTimeMetadataKey, EventKinds, EventTags } from '../../../../src/constants/base' + +const now = (): number => Math.floor(Date.now() / 1000) + +const wait = async (ms: number): Promise => { + if (ms <= 0) { + return + } + + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +const createTextNoteWithExpiration = async ( + world: World>, + name: string, + content: string, + expirationTime: number, +): Promise => { + const ws = world.parameters.clients[name] as WebSocket + const { pubkey, privkey } = world.parameters.identities[name] + + const event = await createEvent( + { + pubkey, + kind: EventKinds.TEXT_NOTE, + content, + tags: [[EventTags.Expiration, expirationTime.toString()]], + }, + privkey, + ) as ExpiringEvent + + event[EventExpirationTimeMetadataKey] = expirationTime + + await publishEvent(ws, event) + + world.parameters.events[name].push(event) + + return event +} + +When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in the past$/, async function( + this: World>, + name: string, + content: string, +) { + await createTextNoteWithExpiration(this, name, content, now() - 10) +}) + +When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in the future$/, async function( + this: World>, + name: string, + content: string, +) { + await createTextNoteWithExpiration(this, name, content, now() + 30) +}) + +When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in (\d+) seconds$/, async function( + this: World>, + name: string, + content: string, + durationSeconds: string, +) { + const expirationTime = now() + Number(durationSeconds) + const event = await createTextNoteWithExpiration(this, name, content, expirationTime) + + expect(event[EventExpirationTimeMetadataKey]).to.equal(expirationTime) +}) + +When(/^(\w+) waits until (\w+)'s last text_note event expires$/, async function( + this: World>, + _name: string, + author: string, +) { + const events = this.parameters.events[author] as Event[] + const event = events[events.length - 1] as ExpiringEvent + const expirationTime = event[EventExpirationTimeMetadataKey] + + expect(expirationTime).to.be.a('number') + + const millisecondsUntilExpired = (Number(expirationTime) - now() + 1) * 1000 + + await wait(millisecondsUntilExpired) +}) + +When(/^(\w+) subscribes to text_note events from (\w+)$/, async function( + this: World>, + name: string, + author: string, +) { + const ws = this.parameters.clients[name] as WebSocket + const authorPubkey = this.parameters.identities[author].pubkey + const subscription = { + name: `test-${Math.random()}`, + filters: [{ kinds: [EventKinds.TEXT_NOTE], authors: [authorPubkey] }], + } + + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) +}) + +Then(/^(\w+) receives (\d+) text_note events and EOSE$/, async function( + this: World>, + name: string, + count: string, +) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const events = await waitForEventCount(ws, subscription.name, Number(count), true) + + expect(events.length).to.equal(Number(count)) + + events.forEach((event) => { + expect(event.kind).to.equal(EventKinds.TEXT_NOTE) + }) +}) From b9f1fb16b04d3eaf3d2fac06f78e130c2c32530f Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Sun, 19 Apr 2026 20:00:16 +0530 Subject: [PATCH 2/6] chore: add empty changeset for test-only PR --- .changeset/empty-nip40-tests.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/empty-nip40-tests.md diff --git a/.changeset/empty-nip40-tests.md b/.changeset/empty-nip40-tests.md new file mode 100644 index 00000000..a1484b6d --- /dev/null +++ b/.changeset/empty-nip40-tests.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only NIP-40 integration coverage; no release version bump required. From c4aa6cf24147d6568c8c1d4ccbc2a25fea6800df Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Mon, 20 Apr 2026 02:00:59 +0530 Subject: [PATCH 3/6] fix: prevent replay of expired NIP-40 events --- src/handlers/subscribe-message-handler.ts | 8 +++++++- .../features/nip-40/nip-40.feature.ts | 4 +++- .../handlers/subscribe-message-handler.spec.ts | 18 +++++++++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index 1ab4e322..06904cbe 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -64,6 +64,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { await pipeline( findEvents, streamFilter(propSatisfies(isNil, 'deleted_at')), + streamFilter(SubscribeMessageHandler.isNotExpired), streamMap(toNostrEvent), streamFilter(isSubscribedToEvent), streamEach(sendEvent), @@ -84,6 +85,11 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { return anyPass(map(isEventMatchingFilter)(filters)) } + private static isNotExpired(event: { expires_at?: number }): boolean { + const now = Math.floor(Date.now() / 1000) + return typeof event.expires_at !== 'number' || event.expires_at > now + } + private canSubscribe(subscriptionId: SubscriptionId, filters: SubscriptionFilter[]): string | undefined { const subscriptions = this.webSocket.getSubscriptions() const existingSubscription = subscriptions.get(subscriptionId) @@ -108,7 +114,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { } if ( - typeof subscriptionLimits.maxSubscriptionIdLength === 'number' + typeof subscriptionLimits?.maxSubscriptionIdLength === 'number' && subscriptionId.length > subscriptionLimits.maxSubscriptionIdLength ) { return `Subscription ID too long: Subscription ID must be less or equal to ${subscriptionLimits.maxSubscriptionIdLength}` diff --git a/test/integration/features/nip-40/nip-40.feature.ts b/test/integration/features/nip-40/nip-40.feature.ts index 7ff79129..fcb7b79a 100644 --- a/test/integration/features/nip-40/nip-40.feature.ts +++ b/test/integration/features/nip-40/nip-40.feature.ts @@ -68,8 +68,10 @@ When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in (\ ) { const expirationTime = now() + Number(durationSeconds) const event = await createTextNoteWithExpiration(this, name, content, expirationTime) + const expirationTag = event.tags.find((tag) => tag[0] === EventTags.Expiration) - expect(event[EventExpirationTimeMetadataKey]).to.equal(expirationTime) + expect(expirationTag).to.not.equal(undefined) + expect(Number(expirationTag?.[1])).to.equal(expirationTime) }) When(/^(\w+) waits until (\w+)'s last text_note event expires$/, async function( diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index adcdbb5a..85a8fd6d 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -17,7 +17,7 @@ import { WebSocketAdapterEvent } from '../../../src/constants/adapter' chai.use(chaiAsPromised) const { expect } = chai -const toDbEvent = (event: Event) => ({ +const toDbEvent = (event: Event, override: Record = {}) => ({ event_id: Buffer.from(event.id, 'hex'), event_kind: event.kind, event_pubkey: Buffer.from(event.pubkey, 'hex'), @@ -25,6 +25,7 @@ const toDbEvent = (event: Event) => ({ event_content: event.content, event_tags: event.tags, event_signature: Buffer.from(event.sig, 'hex'), + ...override, }) describe('SubscribeMessageHandler', () => { @@ -165,6 +166,21 @@ describe('SubscribeMessageHandler', () => { ) }) + it('does not send expired stored events', async () => { + isClientSubscribedToEventStub.returns(always(true)) + + const promise = (handler as any).fetchAndSend(subscriptionId, filters) + + stream.write(toDbEvent(event, { expires_at: Math.floor(Date.now() / 1000) - 1 })) + stream.end() + + await promise + + expect(webSocketOnMessageStub).to.have.been.calledOnceWithExactly( + ['EOSE', subscriptionId], + ) + }) + it('sends EOSE', async () => { const promise = (handler as any).fetchAndSend(subscriptionId, filters) From 442f571dba713e80f655354e36377fcaa51e06d8 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Mon, 20 Apr 2026 03:37:11 +0530 Subject: [PATCH 4/6] refactor: remove duplicate expiration filter in subscription replay --- src/handlers/subscribe-message-handler.ts | 10 ++-------- .../handlers/subscribe-message-handler.spec.ts | 15 --------------- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index 8b558f52..6d5c1e1e 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -59,7 +59,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { const sendEOSE = () => this.webSocket.emit(WebSocketAdapterEvent.Message, createEndOfStoredEventsNoticeMessage(subscriptionId)) const isSubscribedToEvent = SubscribeMessageHandler.isClientSubscribedToEvent(filters) - const isNotExpired = (event: Event) => { + const isTagUnexpired = (event: Event) => { if (isExpiredEvent(event)) { return false } @@ -74,9 +74,8 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { await pipeline( findEvents, streamFilter(propSatisfies(isNil, 'deleted_at')), - streamFilter(SubscribeMessageHandler.isNotExpired), streamMap(toNostrEvent), - streamFilter(isNotExpired), + streamFilter(isTagUnexpired), streamFilter(isSubscribedToEvent), streamEach(sendEvent), streamEnd(sendEOSE), @@ -96,11 +95,6 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { return anyPass(map(isEventMatchingFilter)(filters)) } - private static isNotExpired(event: { expires_at?: number }): boolean { - const now = Math.floor(Date.now() / 1000) - return typeof event.expires_at !== 'number' || event.expires_at > now - } - private canSubscribe(subscriptionId: SubscriptionId, filters: SubscriptionFilter[]): string | undefined { const subscriptions = this.webSocket.getSubscriptions() const existingSubscription = subscriptions.get(subscriptionId) diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 9f088ba4..91b653dc 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -210,21 +210,6 @@ describe('SubscribeMessageHandler', () => { expect(webSocketOnMessageStub).to.have.been.calledWithExactly(['EOSE', subscriptionId]) }) - it('does not send expired stored events', async () => { - isClientSubscribedToEventStub.returns(always(true)) - - const promise = (handler as any).fetchAndSend(subscriptionId, filters) - - stream.write(toDbEvent(event, { expires_at: Math.floor(Date.now() / 1000) - 1 })) - stream.end() - - await promise - - expect(webSocketOnMessageStub).to.have.been.calledOnceWithExactly( - ['EOSE', subscriptionId], - ) - }) - it('sends EOSE', async () => { const promise = (handler as any).fetchAndSend(subscriptionId, filters) From b6f3e101ffb713ebfc3ba4fb0d645c425ce09e1a Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Mon, 20 Apr 2026 04:19:36 +0530 Subject: [PATCH 5/6] test: increase unit coverage for CI --- .../get-health-request-handler.spec.ts | 27 +++++++++++ .../subscribe-message-handler.spec.ts | 46 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 test/unit/handlers/request-handlers/get-health-request-handler.spec.ts diff --git a/test/unit/handlers/request-handlers/get-health-request-handler.spec.ts b/test/unit/handlers/request-handlers/get-health-request-handler.spec.ts new file mode 100644 index 00000000..0396fb3a --- /dev/null +++ b/test/unit/handlers/request-handlers/get-health-request-handler.spec.ts @@ -0,0 +1,27 @@ +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { getHealthRequestHandler } from '../../../../src/handlers/request-handlers/get-health-request-handler' + +chai.use(sinonChai) +const { expect } = chai + +describe('getHealthRequestHandler', () => { + it('responds with OK plain text and calls next', () => { + const req = {} as any + const res = { + status: sinon.stub().returnsThis(), + setHeader: sinon.stub().returnsThis(), + send: sinon.stub().returnsThis(), + } as any + const next = sinon.stub() + + getHealthRequestHandler(req, res, next) + + expect(res.status).to.have.been.calledOnceWithExactly(200) + expect(res.setHeader).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8') + expect(res.send).to.have.been.calledOnceWithExactly('OK') + expect(next).to.have.been.calledOnce + }) +}) diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 91b653dc..4ff024d8 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -236,6 +236,22 @@ describe('SubscribeMessageHandler', () => { await expect(promise).to.eventually.be.rejectedWith(error) expect(closeSpy).to.have.been.called }) + + it('destroys event stream if aborted', async () => { + const error = new Error('aborted') + error.name = 'AbortError' + isClientSubscribedToEventStub.returns(always(true)) + + const fetch = () => (handler as any).fetchAndSend(subscriptionId, filters) + const destroySpy = sandbox.spy(stream, 'destroy') + + const promise = fetch() + + stream.emit('error', error) + + await expect(promise).to.eventually.be.rejectedWith(error) + expect(destroySpy).to.have.been.called + }) }) describe('.isClientSubscribedToEvent', () => { @@ -350,5 +366,35 @@ describe('SubscribeMessageHandler', () => { 'Too many filters: Number of filters per susbscription must be less then or equal to 1', ) }) + + it('returns reason if subscription id is too long', () => { + settingsFactory.returns({ + limits: { + client: { + subscription: { + maxSubscriptionIdLength: 5, + }, + }, + }, + }) + + expect((handler as any).canSubscribe('123456', filters)).to.equal( + 'Subscription ID too long: Subscription ID must be less or equal to 5', + ) + }) + + it('returns undefined if subscription id matches max length', () => { + settingsFactory.returns({ + limits: { + client: { + subscription: { + maxSubscriptionIdLength: 6, + }, + }, + }, + }) + + expect((handler as any).canSubscribe('123456', filters)).to.be.undefined + }) }) }) From 73bc06b89d62ba5bafc9f6c4dde86220ba22b6fd Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Mon, 20 Apr 2026 18:30:41 +0530 Subject: [PATCH 6/6] test: avoid real-time waits in NIP-40 integration --- .../features/nip-40/nip-40.feature | 5 +- .../features/nip-40/nip-40.feature.ts | 67 ++++++++++++------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/test/integration/features/nip-40/nip-40.feature b/test/integration/features/nip-40/nip-40.feature index 509cd7dd..4f222758 100644 --- a/test/integration/features/nip-40/nip-40.feature +++ b/test/integration/features/nip-40/nip-40.feature @@ -16,10 +16,9 @@ Feature: NIP-40 Event expiration for standalone events And Bob subscribes to text_note events from Alice Then Bob receives a text_note event from Alice with content "not yet expired" - Scenario: Stored event is not returned to new subscribers after expiration time passes + Scenario: Stored expired event is not returned to new subscribers Given someone called Alice And someone called Bob - When Alice sends a text_note event with content "short lived" and expiration in 2 seconds - And Bob waits until Alice's last text_note event expires + When Alice has a stored text_note event with content "short lived" and expiration in the past And Bob subscribes to text_note events from Alice Then Bob receives 0 text_note events and EOSE diff --git a/test/integration/features/nip-40/nip-40.feature.ts b/test/integration/features/nip-40/nip-40.feature.ts index fcb7b79a..176da159 100644 --- a/test/integration/features/nip-40/nip-40.feature.ts +++ b/test/integration/features/nip-40/nip-40.feature.ts @@ -3,19 +3,13 @@ import { expect } from 'chai' import WebSocket from 'ws' import { createEvent, createSubscription, publishEvent, waitForEventCount } from '../helpers' -import { Event, ExpiringEvent } from '../../../../src/@types/event' +import { ExpiringEvent } from '../../../../src/@types/event' import { EventExpirationTimeMetadataKey, EventKinds, EventTags } from '../../../../src/constants/base' +import { getMasterDbClient } from '../../../../src/database/client' +import { EventRepository } from '../../../../src/repositories/event-repository' const now = (): number => Math.floor(Date.now() / 1000) -const wait = async (ms: number): Promise => { - if (ms <= 0) { - return - } - - await new Promise((resolve) => setTimeout(resolve, ms)) -} - const createTextNoteWithExpiration = async ( world: World>, name: string, @@ -44,6 +38,37 @@ const createTextNoteWithExpiration = async ( return event } +const seedStoredTextNoteWithExpiration = async ( + world: World>, + name: string, + content: string, + expirationTime: number, +): Promise => { + const { pubkey, privkey } = world.parameters.identities[name] + const dbClient = getMasterDbClient() + const repository = new EventRepository(dbClient, dbClient) + + const event = await createEvent( + { + pubkey, + kind: EventKinds.TEXT_NOTE, + created_at: expirationTime - 30, + content, + tags: [[EventTags.Expiration, expirationTime.toString()]], + }, + privkey, + ) as ExpiringEvent + + event[EventExpirationTimeMetadataKey] = expirationTime + + const inserted = await repository.create(event) + expect(inserted).to.equal(1) + + world.parameters.events[name].push(event) + + return event +} + When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in the past$/, async function( this: World>, name: string, @@ -60,6 +85,14 @@ When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in th await createTextNoteWithExpiration(this, name, content, now() + 30) }) +When(/^(\w+) has a stored text_note event with content "([^"]+)" and expiration in the past$/, async function( + this: World>, + name: string, + content: string, +) { + await seedStoredTextNoteWithExpiration(this, name, content, now() - 10) +}) + When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in (\d+) seconds$/, async function( this: World>, name: string, @@ -74,22 +107,6 @@ When(/^(\w+) sends a text_note event with content "([^"]+)" and expiration in (\ expect(Number(expirationTag?.[1])).to.equal(expirationTime) }) -When(/^(\w+) waits until (\w+)'s last text_note event expires$/, async function( - this: World>, - _name: string, - author: string, -) { - const events = this.parameters.events[author] as Event[] - const event = events[events.length - 1] as ExpiringEvent - const expirationTime = event[EventExpirationTimeMetadataKey] - - expect(expirationTime).to.be.a('number') - - const millisecondsUntilExpired = (Number(expirationTime) - now() + 1) * 1000 - - await wait(millisecondsUntilExpired) -}) - When(/^(\w+) subscribes to text_note events from (\w+)$/, async function( this: World>, name: string,