Files
reader/server/test/user-management.test.ts
T

61 lines
5.7 KiB
TypeScript

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');
});