Skip to content

Commit 664168a

Browse files
test: add NIP-22 created_at integration coverage (#547)
* test: add stable NIP-22 created_at limit coverage * test: increase event-message-handler edge-path coverage
1 parent 32a1ec5 commit 664168a

4 files changed

Lines changed: 283 additions & 5 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"nostream": patch
3+
---
4+
5+
Improve NIP-22 `created_at` limit handling coverage and boundary reliability.
6+
7+
This adds integration coverage for accepted and rejected events across configured positive and negative `created_at` deltas, and keeps rejection semantics consistent (`rejected`) for out-of-range timestamps.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
@nip-22
2+
Feature: NIP-22 created_at timestamp limits
3+
Scenario: Event with created_at at current time is accepted
4+
Given someone called Alice
5+
And created_at limits are set to maxPositiveDelta 900 and maxNegativeDelta 0
6+
When Alice drafts a text_note event with content "test event" and created_at 0 seconds from now
7+
Then Alice sends their last draft event successfully
8+
When Alice subscribes to author Alice
9+
Then Alice receives a text_note event from Alice with content "test event"
10+
11+
Scenario: Event with created_at above positive delta limit is rejected
12+
Given someone called Alice
13+
And created_at limits are set to maxPositiveDelta 900 and maxNegativeDelta 0
14+
When Alice drafts a text_note event with content "test event" and created_at 910 seconds from now
15+
Then Alice sends their last draft event unsuccessfully with reason containing "rejected"
16+
17+
Scenario: Event older than configured negative delta limit is rejected
18+
Given someone called Alice
19+
And created_at limits are set to maxPositiveDelta 900 and maxNegativeDelta 3600
20+
When Alice drafts a text_note event with content "test event" and created_at -3601 seconds from now
21+
Then Alice sends their last draft event unsuccessfully with reason containing "rejected"
22+
23+
Scenario: Event within configured negative delta limit is accepted
24+
Given someone called Alice
25+
And created_at limits are set to maxPositiveDelta 900 and maxNegativeDelta 3600
26+
When Alice drafts a text_note event with content "test event" and created_at -3590 seconds from now
27+
Then Alice sends their last draft event successfully
28+
When Alice subscribes to author Alice
29+
Then Alice receives a text_note event from Alice with content "test event"
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { After, Before, Given, Then, When } from '@cucumber/cucumber'
2+
import { assocPath, pipe } from 'ramda'
3+
4+
import { CommandResult, MessageType } from '../../../../src/@types/messages'
5+
import { createEvent, sendEvent } from '../helpers'
6+
7+
import { Event } from '../../../../src/@types/event'
8+
import { expect } from 'chai'
9+
import { isDraft } from '../shared'
10+
import { SettingsStatic } from '../../../../src/utils/settings'
11+
import WebSocket from 'ws'
12+
13+
const previousSettingsSnapshot = Symbol('nip22PreviousSettingsSnapshot')
14+
const draftOffsetSeconds = Symbol('nip22DraftOffsetSeconds')
15+
16+
const setCreatedAtLimits = (maxPositiveDelta: number, maxNegativeDelta: number) => {
17+
const settings = SettingsStatic._settings ?? SettingsStatic.createSettings()
18+
19+
SettingsStatic._settings = pipe(
20+
assocPath(['limits', 'event', 'createdAt', 'maxPositiveDelta'], maxPositiveDelta),
21+
assocPath(['limits', 'event', 'createdAt', 'maxNegativeDelta'], maxNegativeDelta),
22+
)(settings) as any
23+
}
24+
25+
Before({ tags: '@nip-22' }, function(this: any) {
26+
this[previousSettingsSnapshot] = SettingsStatic._settings
27+
})
28+
29+
After({ tags: '@nip-22' }, function(this: any) {
30+
SettingsStatic._settings = this[previousSettingsSnapshot]
31+
delete this[previousSettingsSnapshot]
32+
})
33+
34+
Given(/^created_at limits are set to maxPositiveDelta (\d+) and maxNegativeDelta (\d+)$/, function(
35+
maxPositiveDelta: string,
36+
maxNegativeDelta: string,
37+
) {
38+
setCreatedAtLimits(Number(maxPositiveDelta), Number(maxNegativeDelta))
39+
})
40+
41+
When(/^(\w+) drafts a text_note event with content "([^"]+)" and created_at (-?\d+) seconds from now$/, async function(
42+
name: string,
43+
content: string,
44+
offsetSeconds: string,
45+
) {
46+
const { pubkey, privkey } = this.parameters.identities[name]
47+
const createdAt = Math.floor(Date.now() / 1000) + Number(offsetSeconds)
48+
49+
const event: Event = await createEvent(
50+
{
51+
pubkey,
52+
kind: 1,
53+
content,
54+
created_at: createdAt,
55+
},
56+
privkey,
57+
)
58+
59+
const draftEvent = event as any
60+
draftEvent[isDraft] = true
61+
draftEvent[draftOffsetSeconds] = Number(offsetSeconds)
62+
63+
this.parameters.events[name].push(event)
64+
})
65+
66+
Then(/^(\w+) sends their last draft event unsuccessfully with reason containing "([^"]+)"$/, async function(
67+
name: string,
68+
expectedReason: string,
69+
) {
70+
const ws = this.parameters.clients[name] as WebSocket
71+
72+
const event = this.parameters.events[name].findLast((lastEvent: Event) => (lastEvent as any)[isDraft])
73+
if (!event) {
74+
throw new Error(`No draft event found for ${name}`)
75+
}
76+
77+
const draftEvent = event as any
78+
const offsetSeconds = draftEvent[draftOffsetSeconds]
79+
80+
let eventToSend = event
81+
if (typeof offsetSeconds === 'number') {
82+
const { pubkey, privkey } = this.parameters.identities[name]
83+
const createdAt = Math.floor(Date.now() / 1000) + offsetSeconds
84+
85+
eventToSend = await createEvent(
86+
{
87+
pubkey,
88+
kind: event.kind,
89+
content: event.content,
90+
created_at: createdAt,
91+
},
92+
privkey,
93+
)
94+
}
95+
96+
delete draftEvent[isDraft]
97+
delete draftEvent[draftOffsetSeconds]
98+
99+
const command = await sendEvent(ws, eventToSend, false) as CommandResult
100+
101+
expect(command[0]).to.equal(MessageType.OK)
102+
expect(command[2]).to.equal(false)
103+
expect(command[3].toLowerCase()).to.contain(expectedReason.toLowerCase())
104+
})

test/unit/handlers/event-message-handler.spec.ts

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { identifyEvent, signEvent } from '../../../src/utils/event'
1313
import { IncomingEventMessage, MessageType } from '../../../src/@types/messages'
1414
import { CacheAdmissionState } from '../../../src/constants/caching'
1515
import { Event } from '../../../src/@types/event'
16-
import { EventKinds } from '../../../src/constants/base'
16+
import { EventKinds, EventExpirationTimeMetadataKey, EventTags } from '../../../src/constants/base'
1717
import { EventMessageHandler } from '../../../src/handlers/event-message-handler'
1818
import { IUserRepository } from '../../../src/@types/repositories'
1919
import { IWebSocketAdapter } from '../../../src/@types/adapters'
@@ -172,6 +172,23 @@ describe('EventMessageHandler', () => {
172172
expect(strategyFactoryStub).not.to.have.been.called
173173
})
174174

175+
it('rejects event if NIP-05 verification is required', async () => {
176+
canAcceptEventStub.returns(undefined)
177+
isEventValidStub.resolves(undefined)
178+
isUserAdmitted.resolves(undefined)
179+
sandbox.stub(EventMessageHandler.prototype, 'checkNip05Verification' as any).resolves('blocked: NIP-05 verification required')
180+
181+
await handler.handleMessage(message)
182+
183+
expect(onMessageSpy).to.have.been.calledOnceWithExactly([
184+
MessageType.OK,
185+
event.id,
186+
false,
187+
'blocked: NIP-05 verification required',
188+
])
189+
expect(strategyFactoryStub).not.to.have.been.called
190+
})
191+
175192
it('rejects event if it is expired', async () => {
176193
isEventValidStub.resolves(undefined)
177194

@@ -280,6 +297,14 @@ describe('EventMessageHandler', () => {
280297
})
281298

282299
describe('createdAt', () => {
300+
it('returns undefined if event pubkey equals relay public key', () => {
301+
sandbox.stub(EventMessageHandler.prototype, 'getRelayPublicKey' as any).returns(event.pubkey)
302+
eventLimits.createdAt.maxPositiveDelta = 1
303+
event.created_at += 999
304+
305+
expect((handler as any).canAcceptEvent(event)).to.be.undefined
306+
})
307+
283308
describe('maxPositiveDelta', () => {
284309
it('returns undefined if maxPositiveDelta is zero', () => {
285310
eventLimits.createdAt.maxPositiveDelta = 0
@@ -291,9 +316,9 @@ describe('EventMessageHandler', () => {
291316
eventLimits.createdAt.maxPositiveDelta = 100
292317
event.created_at += 101
293318

294-
expect((handler as any).canAcceptEvent(event)).to.equal(
295-
'rejected: created_at is more than 100 seconds in the future',
296-
)
319+
expect(
320+
(handler as any).canAcceptEvent(event)
321+
).to.equal('rejected: created_at is more than 100 seconds in the future')
297322
})
298323
})
299324

@@ -616,6 +641,22 @@ describe('EventMessageHandler', () => {
616641
}
617642
})
618643

644+
it('returns reason if request to vanish relay tag does not match relay URL', async () => {
645+
const privkey = '0000000000000000000000000000000000000000000000000000000000000001'
646+
const unsignedEvent = await identifyEvent({
647+
pubkey: '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798',
648+
created_at: 1700000000,
649+
kind: EventKinds.REQUEST_TO_VANISH,
650+
tags: [[EventTags.Relay, 'wss://another-relay.example']],
651+
content: '',
652+
})
653+
const vanishEvent = await signEvent(privkey)(unsignedEvent)
654+
655+
return expect((handler as any).isEventValid(vanishEvent)).to.eventually.equal(
656+
'invalid: request to vanish relay tag invalid',
657+
)
658+
})
659+
619660
it('returns undefined if event is valid', () => {
620661
return expect((handler as any).isEventValid(event)).to.eventually.be.undefined
621662
})
@@ -683,6 +724,36 @@ describe('EventMessageHandler', () => {
683724
})
684725
})
685726

727+
describe('isBlockedByRequestToVanish', () => {
728+
beforeEach(() => {
729+
handler = new EventMessageHandler(
730+
{} as any,
731+
() => null,
732+
{} as any,
733+
userRepository,
734+
() =>
735+
({
736+
info: { relay_url: 'relay_url' },
737+
}) as any,
738+
{} as any,
739+
{ hasKey: async () => false, setKey: async () => true } as any,
740+
() => ({ hit: async () => false }),
741+
)
742+
})
743+
744+
it('returns undefined for request to vanish events', async () => {
745+
event.kind = EventKinds.REQUEST_TO_VANISH
746+
747+
return expect((handler as any).isBlockedByRequestToVanish(event)).to.eventually.be.undefined
748+
})
749+
750+
it("returns undefined if event pubkey equals relay's own public key", async () => {
751+
sandbox.stub(EventMessageHandler.prototype, 'getRelayPublicKey' as any).returns(event.pubkey)
752+
753+
return expect((handler as any).isBlockedByRequestToVanish(event)).to.eventually.be.undefined
754+
})
755+
})
756+
686757
describe('isRateLimited', () => {
687758
let eventLimits: EventLimits
688759
let settings: Settings
@@ -743,6 +814,21 @@ describe('EventMessageHandler', () => {
743814
return expect((handler as any).isRateLimited(event)).to.eventually.be.false
744815
})
745816

817+
it("fulfills with false if event pubkey equals relay's own public key", async () => {
818+
sandbox.stub(EventMessageHandler.prototype, 'getRelayPublicKey' as any).returns(event.pubkey)
819+
eventLimits.rateLimits = [
820+
{
821+
period: 60000,
822+
rate: 1,
823+
},
824+
]
825+
826+
const actualResult = await (handler as any).isRateLimited(event)
827+
828+
expect(actualResult).to.be.false
829+
expect(rateLimiterHitStub).not.to.have.been.called
830+
})
831+
746832
it('skips rate limiter if IP is whitelisted', async () => {
747833
eventLimits.rateLimits = [
748834
{
@@ -1098,6 +1184,17 @@ describe('EventMessageHandler', () => {
10981184
})
10991185

11001186
describe('caching', () => {
1187+
it('falls back to repository lookup when cache read fails', async () => {
1188+
cacheStub.getKey.rejects(new Error('cache unavailable'))
1189+
settings.limits.event.pubkey.minBalance = 100n
1190+
userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, balance: 150n })
1191+
1192+
await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined
1193+
1194+
expect(userRepositoryFindByPubkeyStub).to.have.been.calledOnceWithExactly(event.pubkey)
1195+
expect(cacheStub.setKey).to.have.been.calledWith(`${event.pubkey}:is-admitted`, CacheAdmissionState.ADMITTED, 300)
1196+
})
1197+
11011198
it('fulfills with undefined and uses cache hit for admitted user without hitting DB', async () => {
11021199
cacheStub.getKey.resolves(CacheAdmissionState.ADMITTED)
11031200

@@ -1341,6 +1438,35 @@ describe('EventMessageHandler', () => {
13411438
})
13421439
})
13431440

1441+
describe('addExpirationMetadata', () => {
1442+
beforeEach(() => {
1443+
handler = new EventMessageHandler(
1444+
{} as any,
1445+
() => null,
1446+
{} as any,
1447+
userRepository,
1448+
() =>
1449+
({
1450+
info: { relay_url: 'relay_url' },
1451+
}) as any,
1452+
{} as any,
1453+
{ hasKey: async () => false, setKey: async () => true } as any,
1454+
() => ({ hit: async () => false }),
1455+
)
1456+
})
1457+
1458+
it('adds expiration metadata when expiration tag is present', () => {
1459+
const expiringEvent: Event = {
1460+
...event,
1461+
tags: [[EventTags.Expiration, '1665547000']],
1462+
}
1463+
1464+
const enriched = (handler as any).addExpirationMetadata(expiringEvent)
1465+
1466+
expect((enriched as any)[EventExpirationTimeMetadataKey]).to.equal(1665547000)
1467+
})
1468+
})
1469+
13441470
describe('processNip05Metadata', () => {
13451471
let settings: Settings
13461472
let nip05VerificationRepository: any
@@ -1422,6 +1548,18 @@ describe('EventMessageHandler', () => {
14221548
expect(verifyStub).not.to.have.been.called
14231549
})
14241550

1551+
it('ignores delete errors when kind-0 has no nip05 in content', async () => {
1552+
nip05VerificationRepository.deleteByPubkey.rejects(new Error('db down'))
1553+
event.kind = EventKinds.SET_METADATA
1554+
event.content = JSON.stringify({ name: 'alice' })
1555+
1556+
;(handler as any).processNip05Metadata(event)
1557+
await new Promise((resolve) => setTimeout(resolve, 10))
1558+
1559+
expect(nip05VerificationRepository.deleteByPubkey).to.have.been.calledOnceWithExactly(event.pubkey)
1560+
expect(verifyStub).not.to.have.been.called
1561+
})
1562+
14251563
it('does nothing when nip05 identifier is unparseable', async () => {
14261564
event.kind = EventKinds.SET_METADATA
14271565
event.content = JSON.stringify({ nip05: 'invalid-no-at-sign' })
@@ -1969,4 +2107,4 @@ describe('EventMessageHandler', () => {
19692107
expect(nip05VerificationRepository.upsert).to.have.been.calledOnce
19702108
})
19712109
})
1972-
})
2110+
})

0 commit comments

Comments
 (0)