Files
Daan MeijerandClaude Opus 5.5 1322e42fb7
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m44s
Test and deploy / deploy (push) Skipped
Accounts with limits: a Newznab key and a tier per user, daily API and NZB counts, invites
ACCESS=accounts (config/access.ts; open stays the default and behaves as
before): Newznab and the catalog's releases routes name a user (apikey or
the web interface's token), whose tier sets searches and NZBs per UTC day
(Newznab 500/501, the catalog 429) and how far back they see (102/403).
Counted in one upsert per use, so the last one can't be taken twice;
caps, stats and refused requests aren't counted. Signup takes an invite
(SIGNUP), which may give a tier; ace access:invite, access:tier (extends a
running tier), access:suspend. The web interface signs in and shows the
day's usage and the Newznab key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:16:45 +02:00

156 lines
8.6 KiB
TypeScript

import { test } from '@japa/runner'
import { DateTime } from 'luxon'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import User from '#models/user'
import BatchStore from '#services/batch_store'
import AccessService from '#services/access_service'
import { TIERS } from '#config/access'
import { RangeIndexer } from '#lib/edge/index'
import { DEFAULT_RULES } from '#lib/rules/index'
import type { Overview } from '#lib/nntp/index'
const GROUP = 'alt.binaries.test'
function file(name: string, counter: string, parts: number, at: number): Overview[] {
return Array.from({ length: parts }, (_, i) => [at + i, {
'subject': `${counter} - "${name}" yEnc (${i + 1}/${parts})`, 'from': 'p <p@x>', 'date': 'Mon, 05 Oct 2026 10:00:00 +0000',
'message-id': `<${name}-${i + 1}@test>`, ':bytes': String(700000 + i), 'xref': `news.example ${GROUP}:${at + i}`,
}] as Overview)
}
/** Two releases: a show (recent) and a movie posted two years ago. */
async function catalog() {
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
const [n] = await db.table('nodes').insert({ name: 'access-node' }).returning('id')
const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: 1, end_id: 1000, next_id: 1, status: 'leased', node_id: n.id }).returning('id')
const indexer = new RangeIndexer({ group: GROUP, start: 1, end: 1000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } })
indexer.add([...file('Some.Show.S01E01.1080p.WEB.mkv', 'Some.Show.S01E01.1080p.WEB [1/1]', 2, 100), ...file('Old.Movie.2024.1080p.BluRay.mkv', 'Old.Movie.2024.1080p.BluRay [1/1]', 1, 300)], 1001)
indexer.finish()
await BatchStore.store(Number(r.id), Number(n.id), indexer.take())
const show = await db.from('releases').where('name', 'ilike', '%show%').firstOrFail()
const movie = await db.from('releases').where('name', 'ilike', '%movie%').firstOrFail()
await db.from('releases').where('id', movie.id).update({ posted_at: DateTime.now().minus({ years: 2 }).toJSDate() })
return { show: Number(show.id), movie: Number(movie.id) }
}
const user = (email = 'someone@example.com') => User.create({ email, password: 'secret-password' })
test.group('Access with accounts', (group) => {
const free = { ...TIERS.free }
group.each.setup(() => testUtils.db().withGlobalTransaction())
group.each.setup(() => {
AccessService.mode = 'accounts'
AccessService.signup = 'invite'
Object.assign(TIERS.free, { apiHits: 3, grabs: 1, retentionDays: 365 })
return () => {
AccessService.mode = 'open'
AccessService.signup = 'open'
Object.assign(TIERS.free, free)
}
})
test("Newznab takes a user's key, counts searches and grabs per day and keeps to the tier's retention", async ({ client, assert }) => {
const { show, movie } = await catalog()
const someone = await user()
assert.include((await client.get('/api?t=search&q=')).text(), 'code="100"')
assert.include((await client.get('/api?t=search&q=&apikey=0123456789abcdef0123456789abcdef')).text(), 'code="100"')
// caps isn't counted.
assert.include((await client.get(`/api?t=caps&apikey=${someone.apiKey}`)).text(), '<registration available="yes" open="no"/>')
const feed = (await client.get(`/api?t=search&q=&apikey=${someone.apiKey}`)).text()
// The free tier sees a year back: the movie isn't found.
assert.include(feed, 'Some Show S01E01')
assert.notInclude(feed, 'Old Movie')
assert.include(feed, `t=get&amp;id=${show}&amp;apikey=${someone.apiKey}`)
assert.include(feed, '<newznab:apilimits apicurrent="1" apimax="3" grabcurrent="0" grabmax="1"/>')
// Past the retention: refused, and not counted as a grab.
assert.include((await client.get(`/api?t=get&id=${movie}&apikey=${someone.apiKey}`)).text(), 'code="102"')
assert.include((await client.get(`/api?t=get&id=999999&apikey=${someone.apiKey}`)).text(), 'code="300"')
const nzb = await client.get(`/api?t=get&id=${show}&apikey=${someone.apiKey}`)
assert.include(nzb.header('content-type'), 'application/x-nzb')
assert.include((await client.get(`/api?t=get&id=${show}&apikey=${someone.apiKey}`)).text(), 'code="501"')
await client.get(`/api?t=search&q=&apikey=${someone.apiKey}`)
await client.get(`/api?t=search&q=&apikey=${someone.apiKey}`)
assert.include((await client.get(`/api?t=search&q=&apikey=${someone.apiKey}`)).text(), 'code="500"')
assert.deepEqual(await AccessService.usage(someone.id), { apiHits: 3, grabs: 1 })
// VIP sees everything; a tier that ran out is free again.
AccessService.grantTier(someone, 'vip', 30)
await someone.save()
assert.include((await client.get(`/api?t=movie&q=old movie&apikey=${someone.apiKey}`)).text(), 'Old Movie 2024')
someone.tierUntil = DateTime.now().minus({ days: 1 })
await someone.save()
assert.include((await client.get(`/api?t=movie&q=old movie&apikey=${someone.apiKey}`)).text(), 'code="500"')
someone.suspended = true
await someone.save()
assert.include((await client.get(`/api?t=caps&apikey=${someone.apiKey}`)).text(), 'code="101"')
})
test('the catalog needs a token or a key, and counts as Newznab does', async ({ client, assert }) => {
const { show, movie } = await catalog()
const someone = await user()
;(await client.get('/api/v1/releases')).assertStatus(401)
;(await client.get(`/api/v1/releases/${show}/nzb`)).assertStatus(401)
// Stats and how to sign in stay open.
;(await client.get('/api/v1/stats')).assertStatus(200)
assert.deepEqual((await client.get('/api/v1/access')).body(), { mode: 'accounts', signup: 'invite' })
const found = await client.get('/api/v1/releases').loginAs(someone)
found.assertStatus(200)
assert.deepEqual((found.body() as { releases: { id: number }[] }).releases.map((r) => r.id), [show])
;(await client.get(`/api/v1/releases/${movie}`).loginAs(someone)).assertStatus(403)
;(await client.get(`/api/v1/releases/${show}`).loginAs(someone)).assertStatus(200)
// A plain link (the web interface's download button) carries the key.
const nzb = await client.get(`/api/v1/releases/${show}/nzb?apikey=${someone.apiKey}`)
nzb.assertStatus(200)
const over = await client.get(`/api/v1/releases/${show}/nzb?apikey=${someone.apiKey}`)
over.assertStatus(429)
assert.deepEqual(over.body(), { error: 'Download limit reached', code: 501 })
// The refused movie wasn't counted: a third search is left.
;(await client.get('/api/v1/releases').loginAs(someone)).assertStatus(200)
;(await client.get('/api/v1/releases').loginAs(someone)).assertStatus(429)
const profile = ((await client.get('/api/v1/account/profile').loginAs(someone)).body() as any).data
assert.equal(profile.access.tier, 'free')
assert.equal(profile.access.apiKey, someone.apiKey)
assert.deepEqual(profile.access.usage, { apiHits: 3, grabs: 1 })
const reset = (await client.post('/api/v1/account/api-key').loginAs(someone)).body() as { apiKey: string }
assert.notEqual(reset.apiKey, someone.apiKey)
assert.include((await client.get(`/api?t=caps&apikey=${someone.apiKey}`)).text(), 'code="100"')
})
test('signup takes an unused invite, which may give a tier', async ({ client, assert }) => {
const form = (email: string, invite?: string) => ({ email, password: 'secret-password', passwordConfirmation: 'secret-password', fullName: null, invite })
await db.table('invites').insert([{ code: 'plain-invite' }, { code: 'vip-invite', tier: 'vip', tier_days: 365 }])
;(await client.post('/api/v1/auth/signup').json(form('a@example.com'))).assertStatus(403)
;(await client.post('/api/v1/auth/signup').json(form('a@example.com', 'no-such-code'))).assertStatus(403)
// A refused invite leaves no account behind.
assert.isNull(await User.findBy('email', 'a@example.com'))
;(await client.post('/api/v1/auth/signup').json(form('a@example.com', 'plain-invite'))).assertStatus(200)
;(await client.post('/api/v1/auth/signup').json(form('b@example.com', 'plain-invite'))).assertStatus(403)
const vip = await client.post('/api/v1/auth/signup').json(form('c@example.com', 'vip-invite'))
vip.assertStatus(200)
const c = await User.findByOrFail('email', 'c@example.com')
assert.equal(c.tier, 'vip')
assert.closeTo(c.tierUntil!.diffNow('days').days, 365, 1)
assert.match(c.apiKey, /^[0-9a-f]{32}$/)
// Renewed early, the year is added to what's left.
AccessService.grantTier(c, 'vip', 365)
assert.closeTo(c.tierUntil!.diffNow('days').days, 730, 1)
AccessService.signup = 'closed'
;(await client.post('/api/v1/auth/signup').json(form('d@example.com', 'vip-invite'))).assertStatus(403)
})
})