Clarify receive requests and add retry countdowns and device unblock controls

This commit is contained in:
2026-10-14 12:22:07 +09:00
parent ff89daac8c
commit 3852c87e2e
16 changed files with 739 additions and 60 deletions

View File

@@ -0,0 +1,121 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './helpers.js';
import { Limiter, ApiError } from '../src/security.js';
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('rejection cooldown reports time and does not use a successful-transfer quota', async () => {
const f = await fixture();
try {
f.cfg.sessionRate = 2;
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.space(a, c);
const request = await f.request(a, b);
await f.call(b, `/transfer-requests/${request.json().requestId}/reject`, 'POST', {});
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);
}
assert.equal((await f.request(a, c)).statusCode, 200);
} 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();
}
});