import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp,mkdir,rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Db } from '../src/db/index.ts'; import { buildApp } from '../src/http/app.ts'; import { UserService } from '../src/services/users.ts'; import { RegistrationService } from '../src/services/registration.ts'; import type { AppContext } from '../src/http/context.ts'; import type { AppConfig } from '../src/config/index.ts'; test('admin users, persisted registration policy, bounded invites and immediate session revocation',async t=>{ const root=await mkdtemp(join(tmpdir(),'reader-users-'));await mkdir(join(root,'books'));await mkdir(join(root,'data')); const config={booksDir:join(root,'books'),dataDir:join(root,'data'),jwtSecret:'isolated-admin-test-secret',accessTokenTtl:604800,refreshTokenTtl:31536000,logLevel:'silent',corsOrigins:[],webDir:'',publicUrl:''} as unknown as AppConfig; const db=new Db(join(root,'data','reader.db')),users=new UserService(db,config); const app=buildApp({db,config,users} as AppContext); t.after(async()=>{await app.close();db.close();await rm(root,{recursive:true,force:true});}); let registrationClient=0; const register=(username:string,inviteCode?:string)=>app.inject({method:'POST',url:'/api/v1/auth/register',remoteAddress:`192.0.2.${++registrationClient}`,payload:{username,password:'password123',inviteCode}}); const first=await register('owner');assert.equal(first.statusCode,201); const owner=first.json().session,headers={authorization:'Bearer '+owner.accessToken}; const setting=(mode:string)=>app.inject({method:'PATCH',url:'/api/v1/admin/registration',headers,payload:{mode}}); assert.equal((await setting('closed')).statusCode,200);assert.equal((await register('blocked')).statusCode,400); assert.equal((await setting('open')).statusCode,200);assert.equal((await register('public-member')).statusCode,201); assert.equal((await setting('invite')).statusCode,200); assert.equal(new RegistrationService(db).mode(),'invite'); const reopened=new Db(join(root,'data','reader.db'));assert.equal(new RegistrationService(reopened).mode(),'invite');reopened.close(); const instance=(await app.inject({url:'/api/v1/instance'})).json();assert.equal(instance.invitationRequired,true);assert.equal(instance.registrationOpen,true); assert.equal((await register('missing-invite')).json().error.code,'INVITE_REQUIRED'); const minted=await app.inject({method:'POST',url:'/api/v1/admin/invites',headers,payload:{label:'single',days:1,maxUses:1}}); assert.equal(minted.statusCode,201);const {code,invite}=minted.json(); assert.equal((await register('owner',code)).statusCode,409); assert.equal(new RegistrationService(db).invites()[0]!.usedCount,0); const raced=await Promise.all([register('invite-one',code),register('invite-two',code)]); assert.deepEqual(raced.map(r=>r.statusCode).sort(),[201,400]); const member=raced.find(r=>r.statusCode===201)!.json().session,memberHeaders={authorization:'Bearer '+member.accessToken}; assert.equal(member.user.role,'member'); const invites=(await app.inject({url:'/api/v1/admin/registration',headers})).json(); assert.equal(invites.invites.find((i:{id:string})=>i.id===invite.id).usedCount,1); assert.ok(!JSON.stringify(invites).includes(code));assert.ok(!JSON.stringify(invites).includes('code_hash')); for (const [method,url,payload] of [['GET','/api/v1/admin/users',undefined],['POST','/api/v1/admin/users',{username:'attacker',password:'password123'}],['PATCH','/api/v1/admin/registration',{mode:'open'}],['POST','/api/v1/admin/invites',{}],['POST',`/api/v1/admin/users/${owner.user.id}/password`,{password:'password456'}]] as const) { const result=await app.inject({method,url,headers:memberHeaders,...(payload?{payload}:{})});assert.equal(result.statusCode,403); } assert.equal((await app.inject({method:'PATCH',url:`/api/v1/admin/users/${owner.user.id}`,headers,payload:{role:'member'}})).json().error.code,'SELF_LOCKOUT'); assert.equal((await app.inject({method:'PATCH',url:`/api/v1/admin/users/${member.user.id}`,headers,payload:{disabled:'false'}})).statusCode,400); const reset=await app.inject({method:'POST',url:`/api/v1/admin/users/${member.user.id}/password`,headers,payload:{password:'newpassword123'}});assert.equal(reset.statusCode,200); assert.equal((await app.inject({url:'/api/v1/auth/me',headers:memberHeaders})).statusCode,401); assert.equal((await app.inject({method:'POST',url:'/api/v1/auth/refresh',payload:{refreshToken:member.refreshToken}})).statusCode,401); const login=await app.inject({method:'POST',url:'/api/v1/auth/login',payload:{username:member.user.username,password:'newpassword123'}});assert.equal(login.statusCode,200); const newHeaders={authorization:'Bearer '+login.json().accessToken}; assert.equal((await app.inject({method:'PATCH',url:`/api/v1/admin/users/${member.user.id}`,headers,payload:{disabled:true}})).statusCode,200); assert.equal((await app.inject({url:'/api/v1/auth/me',headers:newHeaders})).statusCode,403); assert.equal((await app.inject({method:'PATCH',url:`/api/v1/admin/users/${member.user.id}`,headers,payload:{disabled:false}})).statusCode,200); assert.equal((await app.inject({url:'/api/v1/auth/me',headers:newHeaders})).statusCode,401); const disabled=new RegistrationService(db).create('disabled',1,1);new RegistrationService(db).disable(disabled.invite.id); assert.equal((await register('disabled-invite',disabled.code)).json().error.code,'INVITE_INVALID'); const expired=new RegistrationService(db).create('expired',1,1);db.run('UPDATE registration_invites SET expires_at=0 WHERE id=?',expired.invite.id); assert.equal((await register('expired-invite',expired.code)).json().error.code,'INVITE_INVALID'); });