import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fixture } from './helpers.js'; import { Limiter, ApiError } from '../src/security.js'; import { randomUUID } from 'node:crypto'; import { once } from 'node:events'; import { eq } from 'drizzle-orm'; import * as schema from '../src/db/schema.js'; test('a known reconnecting peer returns a transient code without creating a request or charging quotas', async () => { const f = await fixture(true); try { f.cfg.offlineGraceMs = 1000; f.cfg.pairRate = 1; const a = await f.create('8.8.8.8'), b = await f.create('8.8.8.8'); const disconnected = once(f.service.io.sockets.sockets.get(b.socket.id), 'disconnect'); b.socket.disconnect(); await disconnected; const key = randomUUID(); for (let i = 0; i < 3; i++) { const response = await f.request(a, b, 'test', 'url', 1, key); assert.equal(response.statusCode, 409); assert.equal(response.json().code, 'TARGET_RECONNECTING'); assert.equal((await f.call(a, '/transfer-requests')).json().requests.length, 0); } await f.call(b, '/device/me', 'PATCH', { discoverable: false }); assert.equal((await f.request(a, b, 'test', 'url', 1, key)).json().code, 'TARGET_UNAVAILABLE'); await f.call(b, '/device/me', 'PATCH', { discoverable: true }); await f.connect(b); const response = await f.request(a, b, 'test', 'url', 1, key); assert.equal(response.statusCode, 200); assert.equal( (await f.request(a, b, 'test', 'url', 1, key)).json().requestId, response.json().requestId, ); assert.equal((await f.call(a, '/transfer-requests')).json().requests.length, 1); } finally { await f.close(); } }); test('rate windows report their deadline; denied retries do not charge other quotas', (t) => { let now = 1000; t.mock.method(Date, 'now', () => now); const limiter = new Limiter(); const limits = [ { key: 'request-guest:a', max: 2 }, { key: 'request-pair:a:b', max: 1 }, ]; limiter.hitMany(limits); now += 5000; for (let i = 0; i < 20; i++) assert.throws( () => limiter.hitMany(limits), (err) => { assert(err instanceof ApiError); assert.equal(err.status, 429); assert.equal(err.retryAt, 61000); assert.equal(err.limitScope, 'pair'); return true; }, ); limiter.hitMany([ { key: 'request-guest:a', max: 2 }, { key: 'request-pair:a:c', max: 1 }, ]); assert.throws(() => limiter.hit('request-guest:a', 2)); now = 61000; assert.doesNotThrow(() => limiter.hitMany(limits)); }); test('transfer limit returns Retry-After and cooldown metadata without creating a request', async () => { const f = await fixture(); try { f.cfg.pairRate = 2; const a = await f.create(), b = await f.create(); await f.space(a, b); for (let i = 0; i < 2; i++) { const response = await f.request(a, b); assert.equal(response.statusCode, 200); await f.call(a, `/transfer-requests/${response.json().requestId}/cancel`, 'POST', {}); } const limited = await f.request(a, b); assert.equal(limited.statusCode, 429); assert.equal(limited.json().code, 'RATE_LIMIT'); assert.equal(limited.json().limitScope, 'pair'); assert(Number(limited.headers['retry-after']) > 0); assert(Number(limited.headers['retry-after']) <= 60); assert.equal(Number(limited.headers['retry-after']), limited.json().retryAfterSeconds); assert(limited.json().retryAt > Date.now()); const next = await f.request(a, b); assert.equal(next.json().retryAt, limited.json().retryAt); assert.equal( (await f.call(b, '/device/snapshot')).json().requests.filter((r) => r.status === 'pending') .length, 0, ); } finally { await f.close(); } }); test('only three consecutive recent rejections trigger a directional pair cooldown; denied retries do not charge quotas', async () => { const f = await fixture(); try { f.cfg.sessionRate = 4; const a = await f.create(), b = await f.create(), c = await f.create(); await f.space(a, b); await f.space(a, c); for (let i = 0; i < 3; i++) { const request = await f.request(a, b); assert.equal(request.statusCode, 200, `rejection ${i} must allow the next request`); const id = request.json().requestId; assert.equal( (await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode, 200, ); assert.equal( (await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode, 409, ); } let deadline; for (let i = 0; i < 4; i++) { const denied = await f.request(a, b); assert.equal(denied.statusCode, 429); assert.equal(denied.json().code, 'REJECTION_COOLDOWN'); assert.equal(denied.json().limitScope, 'pair'); assert(denied.json().retryAfterSeconds <= 300 && denied.json().retryAfterSeconds > 290); deadline ??= denied.json().retryAt; assert.equal(denied.json().retryAt, deadline, 'retry never extends the cooldown'); } assert.equal((await f.request(a, c)).statusCode, 200); assert.equal((await f.request(b, a)).statusCode, 200, 'reverse direction is independent'); } finally { await f.close(); } }); test('acceptance breaks a rejection streak; cancellation does not bypass it; cooldown expires without deleting history', async () => { const f = await fixture(false, undefined, { pairRate: 30, sessionRate: 50 }); try { const a = await f.create(), b = await f.create(); await f.space(a, b); const reject = async () => { const request = await f.request(a, b); assert.equal(request.statusCode, 200, request.body); const id = request.json().requestId; assert.equal( (await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode, 200, ); return id; }; await reject(); await reject(); await f.complete(a, b, '수락하면 누적 초기화'); await reject(); await reject(); const cancelled = await f.request(a, b); assert.equal(cancelled.statusCode, 200); await f.call(a, `/transfer-requests/${cancelled.json().requestId}/cancel`, 'POST', {}); const id = await reject(); assert.equal((await f.request(a, b)).json().code, 'REJECTION_COOLDOWN'); const cutoff = Date.now() - f.cfg.rejectCooldownMs; // At exactly the third rejection's deadline the same history no longer blocks. f.service.db .update(schema.requests) .set({ rejectedAt: cutoff }) .where(eq(schema.requests.id, id)) .run(); assert.equal((await f.request(a, b)).statusCode, 200); assert.equal( f.service.db.select().from(schema.requests).where(eq(schema.requests.id, id)).get().status, 'rejected', ); } finally { await f.close(); } }); test('block list is private; unblock only removes the current device block and never revives a request', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(), c = await f.create(); await f.space(a, b); const id = (await f.request(a, b)).json().requestId; await f.call(b, `/transfer-requests/${id}/block`, 'POST', {}); assert.equal((await f.call(null, '/device/blocks')).statusCode, 401); assert.deepEqual((await f.call(a, '/device/blocks')).json().blocks, []); const blocks = (await f.call(b, '/device/blocks')).json().blocks; assert.equal(blocks.length, 1); assert.equal(blocks[0].deviceId, a.deviceId); assert.deepEqual(Object.keys(blocks[0]).sort(), ['code', 'deviceId', 'name']); await f.call(c, `/device/blocks/${a.deviceId}`, 'DELETE'); assert.equal((await f.call(b, '/device/blocks')).json().blocks.length, 1); assert.equal((await f.request(a, b)).statusCode, 403); assert.equal((await f.call(b, `/device/blocks/${a.deviceId}`, 'DELETE')).statusCode, 200); assert.deepEqual((await f.call(b, '/device/blocks')).json().blocks, []); assert.equal( (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, crypto.randomUUID())) .statusCode, 409, ); assert.equal((await f.request(a, b)).statusCode, 200); } finally { await f.close(); } });