* move handle resolution to .well-known * required handle on resolveHandle * rm test * tidy * tidy * fix up appview * missing await * atproto-handle -> atproto-did * shift did & handle resolution to new identity package * fix up network mocks * fix up another test * one more * drop lex comment * rm handle param * Update packages/identity/src/handle/index.ts Co-authored-by: devin ivy <devinivy@gmail.com> * still temporarily support xrpc handle resolution * typo * ensure return value is a string --------- Co-authored-by: devin ivy <devinivy@gmail.com>
768 lines
22 KiB
TypeScript
768 lines
22 KiB
TypeScript
import { once, EventEmitter } from 'events'
|
|
import AtpAgent, {
|
|
ComAtprotoServerCreateAccount,
|
|
ComAtprotoServerResetPassword,
|
|
} from '@atproto/api'
|
|
import { IdResolver } from '@atproto/identity'
|
|
import * as crypto from '@atproto/crypto'
|
|
import Mail from 'nodemailer/lib/mailer'
|
|
import { AppContext, Database } from '../src'
|
|
import * as util from './_util'
|
|
import { ServerMailer } from '../src/mailer'
|
|
import { DAY } from '@atproto/common'
|
|
|
|
const email = 'alice@test.com'
|
|
const handle = 'alice.test'
|
|
const password = 'test123'
|
|
const passwordAlt = 'test456'
|
|
const minsToMs = 60 * 1000
|
|
|
|
const createInviteCode = async (
|
|
agent: AtpAgent,
|
|
uses: number,
|
|
forUser?: string,
|
|
): Promise<string> => {
|
|
const res = await agent.api.com.atproto.server.createInviteCode(
|
|
{ useCount: uses, forUser },
|
|
{
|
|
headers: { authorization: util.adminAuth() },
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
return res.data.code
|
|
}
|
|
|
|
describe('account', () => {
|
|
let serverUrl: string
|
|
let ctx: AppContext
|
|
let repoSigningKey: string
|
|
let agent: AtpAgent
|
|
let close: util.CloseFn
|
|
let mailer: ServerMailer
|
|
let db: Database
|
|
let idResolver: IdResolver
|
|
const mailCatcher = new EventEmitter()
|
|
let _origSendMail
|
|
|
|
beforeAll(async () => {
|
|
const server = await util.runTestServer({
|
|
inviteRequired: true,
|
|
userInviteInterval: DAY,
|
|
termsOfServiceUrl: 'https://example.com/tos',
|
|
privacyPolicyUrl: '/privacy-policy',
|
|
dbPostgresSchema: 'account',
|
|
})
|
|
close = server.close
|
|
mailer = server.ctx.mailer
|
|
db = server.ctx.db
|
|
ctx = server.ctx
|
|
serverUrl = server.url
|
|
repoSigningKey = server.ctx.repoSigningKey.did()
|
|
idResolver = new IdResolver({ plcUrl: ctx.cfg.didPlcUrl })
|
|
agent = new AtpAgent({ service: serverUrl })
|
|
|
|
// Catch emails for use in tests
|
|
_origSendMail = mailer.transporter.sendMail
|
|
mailer.transporter.sendMail = async (opts) => {
|
|
const result = await _origSendMail.call(mailer.transporter, opts)
|
|
mailCatcher.emit('mail', opts)
|
|
return result
|
|
}
|
|
})
|
|
|
|
afterAll(async () => {
|
|
mailer.transporter.sendMail = _origSendMail
|
|
if (close) {
|
|
await close()
|
|
}
|
|
})
|
|
|
|
let inviteCode: string
|
|
|
|
it('creates an invite code', async () => {
|
|
inviteCode = await createInviteCode(agent, 1)
|
|
const split = inviteCode.split('-')
|
|
const host = split.slice(0, -2).join('.')
|
|
const code = split.slice(-2).join('-')
|
|
expect(host).toBe('pds.public.url') // Hostname of public url
|
|
expect(code.length).toBe(11)
|
|
})
|
|
|
|
it('serves the accounts system config', async () => {
|
|
const res = await agent.api.com.atproto.server.describeServer({})
|
|
expect(res.data.inviteCodeRequired).toBe(true)
|
|
expect(res.data.availableUserDomains[0]).toBe('.test')
|
|
expect(typeof res.data.inviteCodeRequired).toBe('boolean')
|
|
expect(res.data.links?.privacyPolicy).toBe(
|
|
'https://pds.public.url/privacy-policy',
|
|
)
|
|
expect(res.data.links?.termsOfService).toBe('https://example.com/tos')
|
|
})
|
|
|
|
it('fails on invalid handles', async () => {
|
|
const promise = agent.api.com.atproto.server.createAccount({
|
|
email: 'bad-handle@test.com',
|
|
handle: 'did:bad-handle.test',
|
|
password: 'asdf',
|
|
inviteCode,
|
|
})
|
|
await expect(promise).rejects.toThrow('Input/handle must be a valid handle')
|
|
})
|
|
|
|
it('fails on bad invite code', async () => {
|
|
const promise = agent.api.com.atproto.server.createAccount({
|
|
email,
|
|
handle,
|
|
password,
|
|
inviteCode: 'fake-invite',
|
|
})
|
|
await expect(promise).rejects.toThrow(
|
|
ComAtprotoServerCreateAccount.InvalidInviteCodeError,
|
|
)
|
|
})
|
|
|
|
let did: string
|
|
let jwt: string
|
|
|
|
it('creates an account', async () => {
|
|
const res = await agent.api.com.atproto.server.createAccount({
|
|
email,
|
|
handle,
|
|
password,
|
|
inviteCode,
|
|
})
|
|
did = res.data.did
|
|
jwt = res.data.accessJwt
|
|
|
|
expect(typeof jwt).toBe('string')
|
|
expect(did.startsWith('did:plc:')).toBeTruthy()
|
|
expect(res.data.handle).toEqual(handle)
|
|
})
|
|
|
|
it('generates a properly formatted PLC DID', async () => {
|
|
const didData = await idResolver.did.resolveAtprotoData(did)
|
|
|
|
expect(didData.did).toBe(did)
|
|
expect(didData.handle).toBe(handle)
|
|
expect(didData.signingKey).toBe(repoSigningKey)
|
|
expect(didData.pds).toBe('https://pds.public.url') // Mapped from publicUrl
|
|
})
|
|
|
|
it('allows a custom set recovery key', async () => {
|
|
const inviteCode = await createInviteCode(agent, 1)
|
|
const recoveryKey = (await crypto.EcdsaKeypair.create()).did()
|
|
const res = await agent.api.com.atproto.server.createAccount({
|
|
email: 'custom-recovery@test.com',
|
|
handle: 'custom-recovery.test',
|
|
password: 'custom-recovery',
|
|
inviteCode,
|
|
recoveryKey,
|
|
})
|
|
|
|
const didData = await ctx.plcClient.getDocumentData(res.data.did)
|
|
|
|
expect(didData.rotationKeys).toEqual([
|
|
recoveryKey,
|
|
ctx.cfg.recoveryKey,
|
|
ctx.plcRotationKey.did(),
|
|
])
|
|
})
|
|
|
|
it('allows a user to bring their own DID', async () => {
|
|
const inviteCode = await createInviteCode(agent, 1)
|
|
const userKey = await crypto.Secp256k1Keypair.create()
|
|
const handle = 'byo-did.test'
|
|
const did = await ctx.plcClient.createDid({
|
|
signingKey: ctx.repoSigningKey.did(),
|
|
handle,
|
|
rotationKeys: [
|
|
userKey.did(),
|
|
ctx.cfg.recoveryKey,
|
|
ctx.plcRotationKey.did(),
|
|
],
|
|
pds: ctx.cfg.publicUrl,
|
|
signer: userKey,
|
|
})
|
|
|
|
const res = await agent.api.com.atproto.server.createAccount({
|
|
email: 'byo-did@test.com',
|
|
handle,
|
|
did,
|
|
password: 'byo-did-pass',
|
|
inviteCode,
|
|
})
|
|
|
|
expect(res.data.handle).toEqual(handle)
|
|
expect(res.data.did).toEqual(did)
|
|
})
|
|
|
|
it('requires that the did a user brought be correctly set up for the server', async () => {
|
|
const inviteCode = await createInviteCode(agent, 1)
|
|
const userKey = await crypto.Secp256k1Keypair.create()
|
|
const baseDidInfo = {
|
|
signingKey: ctx.repoSigningKey.did(),
|
|
handle: 'byo-did.test',
|
|
rotationKeys: [
|
|
userKey.did(),
|
|
ctx.cfg.recoveryKey,
|
|
ctx.plcRotationKey.did(),
|
|
],
|
|
pds: ctx.cfg.publicUrl,
|
|
signer: userKey,
|
|
}
|
|
const baseAccntInfo = {
|
|
email: 'byo-did@test.com',
|
|
handle: 'byo-did.test',
|
|
password: 'byo-did-pass',
|
|
inviteCode,
|
|
}
|
|
|
|
const did1 = await ctx.plcClient.createDid({
|
|
...baseDidInfo,
|
|
handle: 'different-handle.test',
|
|
})
|
|
const attempt1 = agent.api.com.atproto.server.createAccount({
|
|
...baseAccntInfo,
|
|
did: did1,
|
|
})
|
|
await expect(attempt1).rejects.toThrow(
|
|
'provided handle does not match DID document handle',
|
|
)
|
|
|
|
const did2 = await ctx.plcClient.createDid({
|
|
...baseDidInfo,
|
|
pds: 'https://other-pds.com',
|
|
})
|
|
const attempt2 = agent.api.com.atproto.server.createAccount({
|
|
...baseAccntInfo,
|
|
did: did2,
|
|
})
|
|
await expect(attempt2).rejects.toThrow(
|
|
'DID document pds endpoint does not match service endpoint',
|
|
)
|
|
|
|
const did3 = await ctx.plcClient.createDid({
|
|
...baseDidInfo,
|
|
rotationKeys: [userKey.did()],
|
|
})
|
|
const attempt3 = agent.api.com.atproto.server.createAccount({
|
|
...baseAccntInfo,
|
|
did: did3,
|
|
})
|
|
await expect(attempt3).rejects.toThrow(
|
|
'PLC DID does not include service rotation key',
|
|
)
|
|
|
|
const did4 = await ctx.plcClient.createDid({
|
|
...baseDidInfo,
|
|
signingKey: userKey.did(),
|
|
})
|
|
const attempt4 = agent.api.com.atproto.server.createAccount({
|
|
...baseAccntInfo,
|
|
did: did4,
|
|
})
|
|
await expect(attempt4).rejects.toThrow(
|
|
'DID document signing key does not match service signing key',
|
|
)
|
|
})
|
|
|
|
it('allows administrative email updates', async () => {
|
|
await agent.api.com.atproto.admin.updateAccountEmail(
|
|
{
|
|
account: handle,
|
|
email: 'alIce-NEw@teST.com',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: util.adminAuth() },
|
|
},
|
|
)
|
|
|
|
const accnt = await ctx.services.account(ctx.db).getAccount(handle)
|
|
expect(accnt?.email).toBe('alice-new@test.com')
|
|
|
|
await agent.api.com.atproto.admin.updateAccountEmail(
|
|
{
|
|
account: did,
|
|
email,
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: util.adminAuth() },
|
|
},
|
|
)
|
|
|
|
const accnt2 = await ctx.services.account(ctx.db).getAccount(handle)
|
|
expect(accnt2?.email).toBe(email)
|
|
})
|
|
|
|
it('disallows non-admin moderators to perform email updates', async () => {
|
|
const attemptUpdate = agent.api.com.atproto.admin.updateAccountEmail(
|
|
{
|
|
account: handle,
|
|
email: 'new@email.com',
|
|
},
|
|
{
|
|
encoding: 'application/json',
|
|
headers: { authorization: util.moderatorAuth() },
|
|
},
|
|
)
|
|
await expect(attemptUpdate).rejects.toThrow('Authentication Required')
|
|
})
|
|
|
|
it('disallows duplicate email addresses and handles', async () => {
|
|
const inviteCode = await createInviteCode(agent, 2)
|
|
const email = 'bob@test.com'
|
|
const handle = 'bob.test'
|
|
const password = 'test123'
|
|
await agent.api.com.atproto.server.createAccount({
|
|
email,
|
|
handle,
|
|
password,
|
|
inviteCode,
|
|
})
|
|
|
|
await expect(
|
|
agent.api.com.atproto.server.createAccount({
|
|
email: email.toUpperCase(),
|
|
handle: 'carol.test',
|
|
password,
|
|
inviteCode,
|
|
}),
|
|
).rejects.toThrow('Email already taken: BOB@TEST.COM')
|
|
|
|
await expect(
|
|
agent.api.com.atproto.server.createAccount({
|
|
email: 'carol@test.com',
|
|
handle: handle.toUpperCase(),
|
|
password,
|
|
inviteCode,
|
|
}),
|
|
).rejects.toThrow('Handle already taken: bob.test')
|
|
})
|
|
|
|
it('disallows improperly formatted handles', async () => {
|
|
const inviteCode = await createInviteCode(agent, 1)
|
|
const tryHandle = async (handle: string) => {
|
|
await agent.api.com.atproto.server.createAccount({
|
|
email: 'john@test.com',
|
|
handle,
|
|
password: 'test123',
|
|
inviteCode,
|
|
})
|
|
}
|
|
await expect(tryHandle('did:john')).rejects.toThrow(
|
|
'Input/handle must be a valid handle',
|
|
)
|
|
await expect(tryHandle('john.bsky.io')).rejects.toThrow(
|
|
'Not a supported handle domain',
|
|
)
|
|
await expect(tryHandle('j.test')).rejects.toThrow('Handle too short')
|
|
await expect(tryHandle('jayromy-johnber12345678910.test')).rejects.toThrow(
|
|
'Handle too long',
|
|
)
|
|
await expect(tryHandle('jo_hn.test')).rejects.toThrow(
|
|
'Input/handle must be a valid handle',
|
|
)
|
|
await expect(tryHandle('jo!hn.test')).rejects.toThrow(
|
|
'Input/handle must be a valid handle',
|
|
)
|
|
await expect(tryHandle('jo%hn.test')).rejects.toThrow(
|
|
'Input/handle must be a valid handle',
|
|
)
|
|
await expect(tryHandle('jo&hn.test')).rejects.toThrow(
|
|
'Input/handle must be a valid handle',
|
|
)
|
|
await expect(tryHandle('jo*hn.test')).rejects.toThrow(
|
|
'Input/handle must be a valid handle',
|
|
)
|
|
await expect(tryHandle('jo|hn.test')).rejects.toThrow(
|
|
'Input/handle must be a valid handle',
|
|
)
|
|
await expect(tryHandle('jo:hn.test')).rejects.toThrow(
|
|
'Input/handle must be a valid handle',
|
|
)
|
|
await expect(tryHandle('jo/hn.test')).rejects.toThrow(
|
|
'Input/handle must be a valid handle',
|
|
)
|
|
await expect(tryHandle('about.test')).rejects.toThrow('Reserved handle')
|
|
await expect(tryHandle('atp.test')).rejects.toThrow('Reserved handle')
|
|
})
|
|
|
|
it('fails on used up invite code', async () => {
|
|
const promise = agent.api.com.atproto.server.createAccount({
|
|
email: 'bob@test.com',
|
|
handle: 'bob.test',
|
|
password: 'asdf',
|
|
inviteCode,
|
|
})
|
|
await expect(promise).rejects.toThrow(
|
|
ComAtprotoServerCreateAccount.InvalidInviteCodeError,
|
|
)
|
|
})
|
|
|
|
it('handles racing invite code uses', async () => {
|
|
const inviteCode = await createInviteCode(agent, 1)
|
|
const COUNT = 10
|
|
|
|
let successes = 0
|
|
let failures = 0
|
|
const promises: Promise<unknown>[] = []
|
|
for (let i = 0; i < COUNT; i++) {
|
|
const attempt = async () => {
|
|
try {
|
|
await agent.api.com.atproto.server.createAccount({
|
|
email: `user${i}@test.com`,
|
|
handle: `user${i}.test`,
|
|
password: `password`,
|
|
inviteCode,
|
|
})
|
|
successes++
|
|
} catch (err) {
|
|
failures++
|
|
}
|
|
}
|
|
promises.push(attempt())
|
|
}
|
|
await Promise.all(promises)
|
|
expect(successes).toBe(1)
|
|
expect(failures).toBe(9)
|
|
})
|
|
|
|
it('handles racing signups for same handle', async () => {
|
|
const COUNT = 10
|
|
|
|
const invite1 = await createInviteCode(agent, COUNT)
|
|
const invite2 = await createInviteCode(agent, COUNT)
|
|
|
|
let successes = 0
|
|
let failures = 0
|
|
const promises: Promise<unknown>[] = []
|
|
for (let i = 0; i < COUNT; i++) {
|
|
const attempt = async () => {
|
|
try {
|
|
// Use two invites to ensure per-invite locking doesn't
|
|
// give the appearance of fixing a race for handle.
|
|
const invite = i % 2 ? invite1 : invite2
|
|
await agent.api.com.atproto.server.createAccount({
|
|
email: `matching@test.com`,
|
|
handle: `matching.test`,
|
|
password: `password`,
|
|
inviteCode: invite,
|
|
})
|
|
successes++
|
|
} catch (err) {
|
|
failures++
|
|
}
|
|
}
|
|
promises.push(attempt())
|
|
}
|
|
await Promise.all(promises)
|
|
expect(successes).toBe(1)
|
|
expect(failures).toBe(9)
|
|
})
|
|
|
|
it('fails on unauthenticated requests', async () => {
|
|
await expect(agent.api.com.atproto.server.getSession({})).rejects.toThrow()
|
|
})
|
|
|
|
it('logs in', async () => {
|
|
const res = await agent.api.com.atproto.server.createSession({
|
|
identifier: handle,
|
|
password,
|
|
})
|
|
jwt = res.data.accessJwt
|
|
expect(typeof jwt).toBe('string')
|
|
expect(res.data.handle).toBe('alice.test')
|
|
expect(res.data.did).toBe(did)
|
|
expect(res.data.email).toBe(email)
|
|
})
|
|
|
|
it('can perform authenticated requests', async () => {
|
|
agent.api.setHeader('authorization', `Bearer ${jwt}`)
|
|
const res = await agent.api.com.atproto.server.getSession({})
|
|
expect(res.data.did).toBe(did)
|
|
expect(res.data.handle).toBe(handle)
|
|
expect(res.data.email).toBe(email)
|
|
})
|
|
|
|
const getMailFrom = async (promise): Promise<Mail.Options> => {
|
|
const result = await Promise.all([once(mailCatcher, 'mail'), promise])
|
|
return result[0][0]
|
|
}
|
|
|
|
const getTokenFromMail = (mail: Mail.Options) =>
|
|
mail.html?.toString().match(/>([a-z0-9]{5}-[a-z0-9]{5})</i)?.[1]
|
|
|
|
it('can reset account password', async () => {
|
|
const mail = await getMailFrom(
|
|
agent.api.com.atproto.server.requestPasswordReset({ email }),
|
|
)
|
|
|
|
expect(mail.to).toEqual(email)
|
|
expect(mail.html).toContain('Reset your password')
|
|
expect(mail.html).toContain('alice.test')
|
|
|
|
const token = getTokenFromMail(mail)
|
|
|
|
if (token === undefined) {
|
|
return expect(token).toBeDefined()
|
|
}
|
|
|
|
await agent.api.com.atproto.server.resetPassword({
|
|
token,
|
|
password: passwordAlt,
|
|
})
|
|
|
|
// Logs in with new password and not previous password
|
|
await expect(
|
|
agent.api.com.atproto.server.createSession({
|
|
identifier: handle,
|
|
password,
|
|
}),
|
|
).rejects.toThrow('Invalid identifier or password')
|
|
|
|
await expect(
|
|
agent.api.com.atproto.server.createSession({
|
|
identifier: handle,
|
|
password: passwordAlt,
|
|
}),
|
|
).resolves.toBeDefined()
|
|
})
|
|
|
|
it('allows only single-use of password reset token', async () => {
|
|
const mail = await getMailFrom(
|
|
agent.api.com.atproto.server.requestPasswordReset({ email }),
|
|
)
|
|
|
|
const token = getTokenFromMail(mail)
|
|
|
|
if (token === undefined) {
|
|
return expect(token).toBeDefined()
|
|
}
|
|
|
|
// Reset back from passwordAlt to password
|
|
await agent.api.com.atproto.server.resetPassword({ token, password })
|
|
|
|
// Reuse of token fails
|
|
await expect(
|
|
agent.api.com.atproto.server.resetPassword({ token, password }),
|
|
).rejects.toThrow(ComAtprotoServerResetPassword.InvalidTokenError)
|
|
|
|
// Logs in with new password and not previous password
|
|
await expect(
|
|
agent.api.com.atproto.server.createSession({
|
|
identifier: handle,
|
|
password: passwordAlt,
|
|
}),
|
|
).rejects.toThrow('Invalid identifier or password')
|
|
|
|
await expect(
|
|
agent.api.com.atproto.server.createSession({
|
|
identifier: handle,
|
|
password,
|
|
}),
|
|
).resolves.toBeDefined()
|
|
})
|
|
|
|
it('changing password invalidates past refresh tokens', async () => {
|
|
const mail = await getMailFrom(
|
|
agent.api.com.atproto.server.requestPasswordReset({ email }),
|
|
)
|
|
|
|
expect(mail.to).toEqual(email)
|
|
expect(mail.html).toContain('Reset your password')
|
|
expect(mail.html).toContain('alice.test')
|
|
|
|
const token = getTokenFromMail(mail)
|
|
|
|
if (token === undefined) {
|
|
return expect(token).toBeDefined()
|
|
}
|
|
|
|
const session = await agent.api.com.atproto.server.createSession({
|
|
identifier: handle,
|
|
password,
|
|
})
|
|
|
|
await agent.api.com.atproto.server.resetPassword({
|
|
token: token.toLowerCase(), // Reset should work case-insensitively
|
|
password,
|
|
})
|
|
|
|
await expect(
|
|
agent.api.com.atproto.server.refreshSession(undefined, {
|
|
headers: { authorization: `Bearer ${session.data.refreshJwt}` },
|
|
}),
|
|
).rejects.toThrow('Token has been revoked')
|
|
})
|
|
|
|
it('allows only unexpired password reset tokens', async () => {
|
|
await agent.api.com.atproto.server.requestPasswordReset({ email })
|
|
|
|
const user = await db.db
|
|
.updateTable('user_account')
|
|
.where('email', '=', email)
|
|
.set({
|
|
passwordResetGrantedAt: new Date(
|
|
Date.now() - 16 * minsToMs,
|
|
).toISOString(),
|
|
})
|
|
.returning(['passwordResetToken'])
|
|
.executeTakeFirst()
|
|
if (!user?.passwordResetToken) {
|
|
throw new Error('Missing reset token')
|
|
}
|
|
|
|
// Use of expired token fails
|
|
await expect(
|
|
agent.api.com.atproto.server.resetPassword({
|
|
token: user.passwordResetToken,
|
|
password: passwordAlt,
|
|
}),
|
|
).rejects.toThrow(ComAtprotoServerResetPassword.ExpiredTokenError)
|
|
|
|
// Still logs in with previous password
|
|
await expect(
|
|
agent.api.com.atproto.server.createSession({
|
|
identifier: handle,
|
|
password: passwordAlt,
|
|
}),
|
|
).rejects.toThrow('Invalid identifier or password')
|
|
|
|
await expect(
|
|
agent.api.com.atproto.server.createSession({
|
|
identifier: handle,
|
|
password,
|
|
}),
|
|
).resolves.toBeDefined()
|
|
})
|
|
|
|
it('allow users to get available user invites', async () => {
|
|
// first pretend account was made 2 days in the past
|
|
const twoDaysAgo = new Date(Date.now() - 2 * DAY).toISOString()
|
|
await ctx.db.db
|
|
.updateTable('user_account')
|
|
.set({ createdAt: twoDaysAgo })
|
|
.where('did', '=', did)
|
|
.execute()
|
|
const res1 = await agent.api.com.atproto.server.getAccountInviteCodes()
|
|
expect(res1.data.codes.length).toBe(2)
|
|
|
|
// use both invites and confirm we can't get any more
|
|
await ctx.db.db
|
|
.insertInto('invite_code_use')
|
|
.values(
|
|
res1.data.codes.map((code) => ({
|
|
code: code.code,
|
|
usedBy: 'did:example:test',
|
|
usedAt: new Date().toISOString(),
|
|
})),
|
|
)
|
|
.execute()
|
|
const res2 = await agent.api.com.atproto.server.getAccountInviteCodes()
|
|
expect(res2.data.codes.length).toBe(2)
|
|
|
|
// now pretend it was made 10 days ago
|
|
const tenDaysAgo = new Date(Date.now() - 10 * DAY).toISOString()
|
|
await ctx.db.db
|
|
.updateTable('user_account')
|
|
.set({ createdAt: tenDaysAgo })
|
|
.where('did', '=', did)
|
|
.execute()
|
|
|
|
const res3 = await agent.api.com.atproto.server.getAccountInviteCodes({
|
|
includeUsed: false,
|
|
createAvailable: false,
|
|
})
|
|
expect(res3.data.codes.length).toBe(0)
|
|
const res4 = await agent.api.com.atproto.server.getAccountInviteCodes()
|
|
expect(res4.data.codes.length).toBe(7)
|
|
const res5 = await agent.api.com.atproto.server.getAccountInviteCodes({
|
|
includeUsed: false,
|
|
})
|
|
expect(res5.data.codes.length).toBe(5)
|
|
})
|
|
|
|
it('prevents use of disabled codes', async () => {
|
|
const first = await createInviteCode(agent, 1)
|
|
const accntCodes =
|
|
await agent.api.com.atproto.server.getAccountInviteCodes()
|
|
const second = accntCodes.data.codes[0].code
|
|
|
|
// disabled first by code & second by did
|
|
await agent.api.com.atproto.admin.disableInviteCodes(
|
|
{
|
|
codes: [first],
|
|
accounts: [did],
|
|
},
|
|
{
|
|
headers: { authorization: util.adminAuth() },
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
|
|
const attempt = async (code: string) => {
|
|
await agent.api.com.atproto.server.createAccount({
|
|
email: 'disable@test.com',
|
|
handle: 'disable.test',
|
|
inviteCode: code,
|
|
password: 'disabled',
|
|
})
|
|
}
|
|
|
|
await expect(attempt(first)).rejects.toThrow(
|
|
ComAtprotoServerCreateAccount.InvalidInviteCodeError,
|
|
)
|
|
await expect(attempt(second)).rejects.toThrow(
|
|
ComAtprotoServerCreateAccount.InvalidInviteCodeError,
|
|
)
|
|
})
|
|
|
|
it('does not allow disabling all admin codes', async () => {
|
|
const attempt = agent.api.com.atproto.admin.disableInviteCodes(
|
|
{
|
|
accounts: ['admin'],
|
|
},
|
|
{
|
|
headers: { authorization: util.adminAuth() },
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
await expect(attempt).rejects.toThrow('cannot disable admin invite codes')
|
|
})
|
|
|
|
it('creates many invite codes', async () => {
|
|
const accounts = ['did:example:one', 'did:example:two', 'did:example:three']
|
|
const res = await agent.api.com.atproto.server.createInviteCodes(
|
|
{
|
|
useCount: 2,
|
|
codeCount: 2,
|
|
forAccounts: accounts,
|
|
},
|
|
{
|
|
headers: { authorization: util.adminAuth() },
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
expect(res.data.codes.length).toBe(3)
|
|
const fromDb = await ctx.db.db
|
|
.selectFrom('invite_code')
|
|
.selectAll()
|
|
.where('forUser', 'in', accounts)
|
|
.execute()
|
|
expect(fromDb.length).toBe(6)
|
|
const dbCodesByUser = {}
|
|
for (const row of fromDb) {
|
|
expect(row.disabled).toBe(0)
|
|
expect(row.availableUses).toBe(2)
|
|
dbCodesByUser[row.forUser] ??= []
|
|
dbCodesByUser[row.forUser].push(row.code)
|
|
}
|
|
for (const { account, codes } of res.data.codes) {
|
|
expect(codes.length).toBe(2)
|
|
expect(codes.sort()).toEqual(dbCodesByUser[account].sort())
|
|
}
|
|
})
|
|
})
|