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>
156 lines
8.6 KiB
TypeScript
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&id=${show}&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)
|
|
})
|
|
})
|