Something went wrong. Try again.
ATProto-based AI Assistant Chat interface.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258import pino from 'pino'import { describe, expect, it, vi } from 'vitest'import { createApp } from '../../src/app.ts'import { Access, CannotSuspendAdmin } from '../../src/auth/access.ts'import { getAccount, recordLogin } from '../../src/auth/accounts.ts'import { addInvite, isInvited } from '../../src/auth/invites.ts'import { addMember, addPluginMember, createRole } from '../../src/auth/role-store.ts'import type { RoleIdentity } from '../../src/auth/roles.ts'import type { Db } from '../../src/db/index.ts'import { migrateToLatest } from '../../src/db/migrate.ts'import type { Settings } from '../../src/settings/schemas.ts'import { authDeps, fakeOAuth, fakeSession, LOGIN_STATE, ORIGIN, sessionCookie,} from '../helpers/auth.ts'import { testConfig } from '../helpers/config.ts'import { createSqliteDb } from '../helpers/db.ts'import { testRoles, testSettings } from '../helpers/settings.ts'
const SHARE = '/shared/did:plc:owner/3aaaaaaaaaaaa'const logger = pino({ level: 'silent' })
/** An app whose sign-ins are for whichever DID `as` names. */async function setup( values: Partial<Settings> = {}, beforeSignIn?: (db: Db, identity: RoleIdentity) => Promise<void>,) { const db = createSqliteDb() await migrateToLatest(db) const settings = testSettings(db, values) const roles = testRoles(db) const access = new Access({ db, roles, settings, logger }) const onLogin = vi.fn(async () => {}) let as = 'did:plc:alice' const oauth = fakeOAuth({ callback: vi.fn(async () => ({ session: fakeSession(as, 'atproto'), state: LOGIN_STATE })), }) const auth = authDeps(db, { oauth, roles, access, settings, onLogin, beforeSignIn: beforeSignIn && ((identity) => beforeSignIn(db, identity)), }) const app = createApp({ config: testConfig(), db, logger, auth }) const signIn = (did = 'did:plc:alice', next?: string) => { as = did return app.request('/oauth/callback?code=a&state=b', { headers: { cookie: `scn_login=${LOGIN_STATE}${next ? `; scn_next=${encodeURIComponent(next)}` : ''}`, }, }) } const me = async (cookie: string) => (await (await app.request('/api/me', { headers: { cookie } })).json()) as Record< string, unknown > const chats = (cookie: string) => app.request('/api/conversations', { headers: { cookie } }) return { db, app, oauth, onLogin, settings, access, signIn, me, chats }}
const errorOf = (res: Response) => new URL(res.headers.get('location') ?? '', 'http://x').searchParams.get('error') ?? ''
describe('registration modes', () => { it('lets anyone create an account on a fresh install', async () => { const { signIn, onLogin } = await setup() expect((await signIn()).headers.get('location')).toBe('/') expect(onLogin).toHaveBeenCalled() })
it('in invite mode lets in holders of an invite role and added people, and turns away anyone else', async () => { const { db, signIn } = await setup({ access: { registration: 'invite', inviteRoles: ['member'] }, }) await createRole(db, { name: 'member', description: '' }) await addMember(db, 'member', 'did:plc:alice', 'did:plc:admin') await addInvite(db, 'did:plc:bob', 'did:plc:admin') expect((await signIn('did:plc:alice')).headers.get('location')).toBe('/') expect((await signIn('did:plc:bob')).headers.get('location')).toBe('/') expect(errorOf(await signIn('did:plc:carol'))).toContain('did:plc:carol') })
it('in invite mode counts a role set before the access check', async () => { const { db, signIn } = await setup( { access: { registration: 'invite', inviteRoles: ['member'] } }, (db, { did }) => addPluginMember(db, 'member', did, 'plugin:members'), ) await createRole(db, { name: 'member', description: '' }) expect((await signIn()).headers.get('location')).toBe('/') })
it('in closed mode lets in only added people and admins', async () => { const { db, signIn } = await setup({ access: { registration: 'closed', inviteRoles: [] } }) await addInvite(db, 'did:plc:bob', 'did:plc:admin') expect((await signIn('did:plc:admin')).headers.get('location')).toBe('/') expect((await signIn('did:plc:bob')).headers.get('location')).toBe('/') expect(errorOf(await signIn('did:plc:alice'))).toContain('did:plc:alice') })
it('revokes a turned-away user OAuth session and creates no account', async () => { const { db, oauth, onLogin, signIn } = await setup({ access: { registration: 'closed', inviteRoles: [] }, }) await signIn() expect(oauth.revoke).toHaveBeenCalledWith('did:plc:alice') expect(await getAccount(db, 'did:plc:alice')).toBeUndefined() expect(onLogin).not.toHaveBeenCalled() })
it('keeps an existing account working when the mode changes', async () => { const { settings, signIn, chats } = await setup() const cookie = sessionCookie(await signIn()) await settings.set('access', { registration: 'closed' }, 'did:plc:admin') expect((await chats(cookie)).status).not.toBe(403) expect((await signIn()).headers.get('location')).toBe('/') })
it('deletes the added person row once they create an account', async () => { const { db, signIn } = await setup({ access: { registration: 'closed', inviteRoles: [] } }) await addInvite(db, 'did:plc:alice', 'did:plc:admin') await signIn() expect(await isInvited(db, 'did:plc:alice')).toBe(false) })})
describe('suspension', () => { it('refuses the chat app to a suspended account without its reason, and keeps its session for shared chats', async () => { const { access, signIn, chats, me } = await setup() const cookie = sessionCookie(await signIn()) await access.suspend('did:plc:alice', 'did:plc:admin', 'Spamming.') const res = await chats(cookie) expect(res.status).toBe(403) const body = (await res.json()) as { error: string; message: string } expect(body.error).toBe('AccessDenied') expect(body.message).not.toContain('Spamming') expect(await me(cookie)).toMatchObject({ access: 'viewer' }) expect(await access.hasAccess('did:plc:alice')).toBe(false) })
it('turns away a suspended user at the login page, and gives them a viewer session from a share link', async () => { const { access, signIn, me } = await setup() await signIn() await access.suspend('did:plc:alice', 'did:plc:admin', 'Chargeback, see ticket 123') const turnedAway = await signIn() expect(errorOf(turnedAway)).not.toBe('') expect(turnedAway.headers.get('location')).not.toContain('ticket') const viewer = await signIn('did:plc:alice', SHARE) expect(viewer.headers.get('location')).toBe(SHARE) expect(await me(sessionCookie(viewer))).toMatchObject({ access: 'viewer' }) })
it('gives access back on the next request after a restore', async () => { const { access, signIn, chats } = await setup() const cookie = sessionCookie(await signIn()) await access.suspend('did:plc:alice', 'did:plc:admin') expect(await access.restore('did:plc:alice', 'did:plc:admin')).toBe(true) expect((await chats(cookie)).status).not.toBe(403) expect(await access.restore('did:plc:alice', 'did:plc:admin')).toBe(false) })
it('records who suspended an account, and reports whether anything changed', async () => { const { db, access, signIn } = await setup() await signIn() expect(await access.suspend('did:plc:alice', 'plugin:members', 'Revoked')).toBe(true) expect(await access.suspend('did:plc:alice', 'plugin:members')).toBe(false) expect(await access.suspend('did:plc:nobody', 'plugin:members')).toBe(false) expect((await getAccount(db, 'did:plc:alice'))?.suspension).toMatchObject({ by: 'plugin:members', reason: 'Revoked', }) })
it('never suspends an admin', async () => { const { access, signIn } = await setup() await signIn('did:plc:admin') await expect(access.suspend('did:plc:admin', 'plugin:members')).rejects.toBeInstanceOf( CannotSuspendAdmin, ) })})
describe('viewers', () => { const closed = { access: { registration: 'closed' as const, inviteRoles: [] } }
it('signs in someone who may not create an account from a share link as a viewer', async () => { const { db, oauth, onLogin, signIn, me } = await setup(closed) const res = await signIn('did:plc:alice', SHARE) expect(res.headers.get('location')).toBe(SHARE) expect(oauth.revoke).not.toHaveBeenCalled() expect(onLogin).not.toHaveBeenCalled() expect((await getAccount(db, 'did:plc:alice'))?.viewerOnly).toBe(true) expect(await me(sessionCookie(res))).toMatchObject({ access: 'viewer', admin: false, accessMessage: expect.stringContaining('did:plc:alice'), }) })
it('keeps a viewer to /api/me, logout, and shared chats', async () => { const { app, signIn, chats } = await setup(closed) const cookie = sessionCookie(await signIn('did:plc:alice', SHARE)) const blocked = await chats(cookie) expect(blocked.status).toBe(403) expect(await blocked.json()).toMatchObject({ error: 'AccessDenied' }) expect((await app.request('/api/me', { headers: { cookie } })).status).toBe(200) expect((await app.request('/api/shared/did:plc:owner/x', { headers: { cookie } })).status).toBe( 404, ) const logout = await app.request('/api/logout', { method: 'POST', headers: { cookie, origin: ORIGIN }, }) expect(logout.status).toBe(200) })
it('gives someone who may create an account a full account through a share link', async () => { const { onLogin, signIn, me } = await setup() const res = await signIn('did:plc:alice', SHARE) expect(await me(sessionCookie(res))).toMatchObject({ access: 'full', accessMessage: null }) expect(onLogin).toHaveBeenCalled() })
it('asks a viewer who may now create an account to sign in again, which sets them up', async () => { const { db, onLogin, settings, signIn, me, chats } = await setup(closed) const cookie = sessionCookie(await signIn('did:plc:alice', SHARE)) await settings.set('access', { registration: 'open' }, 'did:plc:admin') expect(await me(cookie)).toMatchObject({ access: 'viewer' }) expect((await chats(cookie)).status).toBe(403) expect(await me(sessionCookie(await signIn()))).toMatchObject({ access: 'full' }) expect(onLogin).toHaveBeenCalledOnce() expect((await getAccount(db, 'did:plc:alice'))?.viewerOnly).toBe(false) })})
describe('Access.hasAccess', () => { it('is true for full accounts, and false for viewers, suspended accounts, and unknown DIDs', async () => { const db = createSqliteDb() await migrateToLatest(db) const access = new Access({ db, roles: testRoles(db), settings: testSettings(db), logger }) const base = { handle: null, pdsUrl: 'https://pds.test', spacesAllowed: true } await recordLogin(db, { ...base, did: 'did:plc:full' }) await recordLogin(db, { ...base, did: 'did:plc:viewer', viewer: true }) await recordLogin(db, { ...base, did: 'did:plc:gone' }) await access.suspend('did:plc:gone', 'did:plc:admin') expect(await access.hasAccess('did:plc:full')).toBe(true) expect(await access.hasAccess('did:plc:viewer')).toBe(false) expect(await access.hasAccess('did:plc:gone')).toBe(false) expect(await access.hasAccess('did:plc:nobody')).toBe(false) })})