atproto/packages/pds/tests/account.test.ts
Eric Bailey 922b94ce37
Update email templates (#2767)
* Update email templates

* Update PLC

* Update test with new email string

* Format

* One more test update

* Use handle instead of identifier to match entryway

* Changeset
2024-09-12 10:53:31 -05:00

574 lines
17 KiB
TypeScript

import { AtpAgent, ComAtprotoServerResetPassword } from '@atproto/api'
import * as crypto from '@atproto/crypto'
import { TestNetworkNoAppView } from '@atproto/dev-env'
import { IdResolver } from '@atproto/identity'
import { EventEmitter, once } from 'events'
import Mail from 'nodemailer/lib/mailer'
import { AppContext } from '../src'
import { ServerMailer } from '../src/mailer'
const email = 'alice@test.com'
const handle = 'alice.test'
const password = 'test123'
const passwordAlt = 'test456'
const minsToMs = 60 * 1000
describe('account', () => {
let network: TestNetworkNoAppView
let ctx: AppContext
let agent: AtpAgent
let mailer: ServerMailer
let idResolver: IdResolver
const mailCatcher = new EventEmitter()
let _origSendMail
beforeAll(async () => {
network = await TestNetworkNoAppView.create({
dbPostgresSchema: 'account',
pds: {
contactEmailAddress: 'abuse@example.com',
termsOfServiceUrl: 'https://example.com/tos',
privacyPolicyUrl: 'https://example.com/privacy-policy',
},
})
// @ts-expect-error Error due to circular dependency with the dev-env package
mailer = network.pds.ctx.mailer
// @ts-expect-error Error due to circular dependency with the dev-env package
ctx = network.pds.ctx
idResolver = network.pds.ctx.idResolver
agent = network.pds.getClient()
// 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
await network.close()
})
it('serves the accounts system config', async () => {
const res = await agent.api.com.atproto.server.describeServer({})
expect(res.data.inviteCodeRequired).toBe(false)
expect(res.data.availableUserDomains[0]).toBe('.test')
expect(typeof res.data.inviteCodeRequired).toBe('boolean')
expect(res.data.links?.privacyPolicy).toBe(
'https://example.com/privacy-policy',
)
expect(res.data.links?.termsOfService).toBe('https://example.com/tos')
expect(res.data.contact?.email).toBe('abuse@example.com')
})
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',
})
await expect(promise).rejects.toThrow('Input/handle must be a valid handle')
})
describe('email validation', () => {
it('succeeds on allowed emails', async () => {
const promise = agent.api.com.atproto.server.createAccount({
email: 'ok-email@gmail.com',
handle: 'ok-email.test',
password: 'asdf',
})
await expect(promise).resolves.toBeTruthy()
})
it('fails on disallowed emails', async () => {
const promise = agent.api.com.atproto.server.createAccount({
email: 'bad-email@disposeamail.com',
handle: 'bad-email.test',
password: 'asdf',
})
await expect(promise).rejects.toThrow(
'This email address is not supported, please use a different email.',
)
})
})
let did: string
let jwt: string
it('creates an account', async () => {
const res = await agent.api.com.atproto.server.createAccount({
email,
handle,
password,
})
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)
const signingKey = await network.pds.ctx.actorStore.keypair(did)
expect(didData.did).toBe(did)
expect(didData.handle).toBe(handle)
expect(didData.signingKey).toBe(signingKey.did())
expect(didData.pds).toBe(network.pds.url)
})
it('allows a custom set recovery key', async () => {
const recoveryKey = (await crypto.P256Keypair.create()).did()
const res = await agent.api.com.atproto.server.createAccount({
email: 'custom-recovery@test.com',
handle: 'custom-recovery.test',
password: 'custom-recovery',
recoveryKey,
})
const didData = await ctx.plcClient.getDocumentData(res.data.did)
expect(didData.rotationKeys).toEqual([
recoveryKey,
ctx.cfg.identity.recoveryDidKey,
ctx.plcRotationKey.did(),
])
})
// @NOTE currently disabled until we allow a user to resver a keypair before migration
// it('allows a user to bring their own DID', async () => {
// 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.identity.recoveryDidKey ?? '',
// ctx.plcRotationKey.did(),
// ],
// pds: network.pds.url,
// signer: userKey,
// })
// const res = await agent.api.com.atproto.server.createAccount({
// email: 'byo-did@test.com',
// handle,
// did,
// password: 'byo-did-pass',
// })
// 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 userKey = await crypto.Secp256k1Keypair.create()
// const baseDidInfo = {
// signingKey: ctx.repoSigningKey.did(),
// handle: 'byo-did.test',
// rotationKeys: [
// userKey.did(),
// ctx.cfg.identity.recoveryDidKey ?? '',
// ctx.plcRotationKey.did(),
// ],
// pds: ctx.cfg.service.publicUrl,
// signer: userKey,
// }
// const baseAccntInfo = {
// email: 'byo-did@test.com',
// handle: 'byo-did.test',
// password: 'byo-did-pass',
// }
// 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: network.pds.adminAuthHeaders(),
},
)
const accnt = await ctx.accountManager.getAccount(handle)
expect(accnt?.email).toBe('alice-new@test.com')
await agent.api.com.atproto.admin.updateAccountEmail(
{
account: did,
email,
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
const accnt2 = await ctx.accountManager.getAccount(handle)
expect(accnt2?.email).toBe(email)
})
it('disallows duplicate email addresses and handles', async () => {
const email = 'bob@test.com'
const handle = 'bob.test'
const password = 'test123'
await agent.api.com.atproto.server.createAccount({
email,
handle,
password,
})
await expect(
agent.api.com.atproto.server.createAccount({
email: email.toUpperCase(),
handle: 'carol.test',
password,
}),
).rejects.toThrow('Email already taken: BOB@TEST.COM')
await expect(
agent.api.com.atproto.server.createAccount({
email: 'carol@test.com',
handle: handle.toUpperCase(),
password,
}),
).rejects.toThrow('Handle already taken: bob.test')
})
it('disallows improperly formatted handles', async () => {
const tryHandle = async (handle: string) => {
await agent.api.com.atproto.server.createAccount({
email: 'john@test.com',
handle,
password: 'test123',
})
}
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('handles racing signups for same handle', async () => {
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: `matching@test.com`,
handle: `matching.test`,
password: `password`,
})
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 () => {
// @TODO each test should be able to run independently & concurrently
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 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 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 res = await ctx.accountManager.db.db
.updateTable('email_token')
.where('purpose', '=', 'reset_password')
.where('did', '=', did)
.set({
requestedAt: new Date(Date.now() - 16 * minsToMs).toISOString(),
})
.returning(['token'])
.executeTakeFirst()
if (!res?.token) {
throw new Error('Missing reset token')
}
// Use of expired token fails
await expect(
agent.api.com.atproto.server.resetPassword({
token: res.token,
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('allows an admin to update password', async () => {
const tryUnauthed = agent.api.com.atproto.admin.updateAccountPassword({
did,
password: 'new-admin-pass',
})
await expect(tryUnauthed).rejects.toThrow('Authentication Required')
await agent.api.com.atproto.admin.updateAccountPassword(
{ did, password: 'new-admin-password' },
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
// old password fails
await expect(
agent.api.com.atproto.server.createSession({
identifier: did,
password,
}),
).rejects.toThrow('Invalid identifier or password')
await expect(
agent.api.com.atproto.server.createSession({
identifier: did,
password: 'new-admin-password',
}),
).resolves.toBeDefined()
})
})