[api] show ban details

This commit is contained in:
Devin Zuczek
2026-09-03 11:05:16 -04:00
parent 4bc5f51b9c
commit 3d7ea3cf27
7 changed files with 315 additions and 178 deletions
+92 -26
View File
@@ -750,32 +750,6 @@ describe('public endpoints', () => {
expect(charadesWordsFor(new Date('2026-04-01T23:59:59Z'))).toBe(april)
})
// The client POSTs this with no body, despite it being a pure read; the route answers
// GET as well, and both methods serve the same body.
test.each(['GET', 'POST'])(
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
async (method) => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
{ method }
)
expect(res.status).toBe(200)
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
// reference stub's empty string — the client tells "no message" from a blank one.
expect(await res.json()).toEqual({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
}
)
// A fixed list, in render order — the client shows the buttons in the order they
// arrive, so the order is part of the contract, not just the contents.
test('GET /api/PlayerReporting/v1/voteToKickReasons serves the reasons in order', async () => {
@@ -4531,6 +4505,98 @@ describe('player reports', () => {
test('banFromReport returns null for an unknown report', async () => {
expect(await banFromReport(env.DB, 999_999)).toBeNull()
})
// What the banned player is TOLD. The block screen reads this; it's the same row
// matchmake and login refuse on, described rather than merely enforced.
describe('moderationBlockDetails', () => {
const NOT_BLOCKED = {
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
}
const details = async (method: string, sub: string) => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
{ method, headers: await bearer(sub) }
)
expect(res.status).toBe(200)
return res.json()
}
// The client POSTs this with no body, despite it being a pure read; the route
// answers GET as well, and both methods serve the same body.
test.each(['GET', 'POST'])(
'%s reports "not blocked" for an unbanned player',
async (method) => {
// A report against them that nobody acted on is not a block.
await submit({ PlayerIdReported: '220' }, await bearer())
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
// reference stub's empty string — the client tells "no message" from a blank one.
expect(await details(method, '220')).toEqual(NOT_BLOCKED)
}
)
test('401s without a bearer token', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
)
expect(res.status).toBe(401)
})
// A permanent ban has no end, so Duration is 0 — IsBan is what marks the block.
test('describes a permanent ban', async () => {
await submit(
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
await bearer()
)
const [row] = await getReportsAgainst(env.DB, 221)
await banFromReport(env.DB, row!.id)
expect(await details('POST', '221')).toEqual({
ReportCategory: 102,
Duration: 0,
GameSessionId: 0,
IsBan: true,
IsHostKick: false,
IsVoiceModAutoban: false,
// A fixed message — the report's `details` are the reporter's words, and
// the reporter is not shown to the player they reported, hence null.
Message: 'Rule violation',
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
})
// A timed ban reports the seconds LEFT, not its original length.
test('describes a timed ban with the seconds remaining', async () => {
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 222)
await banFromReport(env.DB, row!.id, {
banExpires: new Date(Date.now() + 3600 * 1000).toISOString(),
})
const body = await details('GET', '222')
expect(body).toMatchObject({ ReportCategory: 103, IsBan: true, Message: 'Rule violation' })
expect(body.Duration).toBeGreaterThan(3500)
expect(body.Duration).toBeLessThanOrEqual(3600)
})
// A ban that has served its time is not a block, even though the row still says
// `banned = 1` — the same rule `getActiveBan` applies for matchmake and login.
test('reports "not blocked" once a ban has expired', async () => {
await submit({ PlayerIdReported: '223' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 223)
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
expect(await details('GET', '223')).toEqual(NOT_BLOCKED)
})
})
})
describe('player warnings', () => {