atproto/packages/pds/tests/invite-codes.test.ts
Matthieu Sieben b934b396b1
Client SDK rework (#2483)
* feat(api): support creation of oauth based AtpAgents

* oauth: misc fixes for confidential clients

* fix(xprc): remove ReadableStream.from polyfill

* OAuth docs tweaks (#2679)

* OAuth: clarification about client_name being shown

* OAuth: re-write handle resolution privacy concern

* avoid relying on ReadableStream.from in xrpc-server tests

* feat(oauth-types): expose "ALLOW_UNSECURE_ORIGINS" constant

* feat(handle-resolver): expose "AtprotoIdentityDidMethods" type

* fix(oauth-client): ensure that the oauth metadata document contains client_id_metadata_document_supported

* fix(oauth-types): prevent unknown query string in loopback client id

* fix(identity-resolver): check that handle is in did doc's "alsoKnownAs"

* feat(oauth-client:oauth-resolver): allow logging in using either the PDS URL or Entryway URL

* fix(oauth-client): return better error in case of invalid "oauth-protected-resource" status code

* refactor(did): group atproto specific checks in own

* feat(api): relax typing of "appLabelers" and "labelers" AtpClient properties

* allow any did as labeller (for tests mainly)

* fix(api): allow to override "atproto-proxy" on a per-request basis

* remove release candidate versions from changelog

* update changeset for api and xrpc packages

* Add missing changeset

* revert RC versions

* Proper wording in OAUTH.md api example

* remove "pre" changeset file

* xrpc: restore original behavior of setHEader and unsetHeader

* docs: add comment for XrpcClient 's constructor arg

* feat(api): expose "schemas" publicly

* feat(api): allow customizing the whatwg fetch function of the AtpAgent

* docs(api): improve migration docs

* docs: change reference to BskyAgent to AtpAgent

* docs: mention the breaking change regarding setSessionPersistHandler

* fix(api): better split AtpClient concerns

* fix(xrpc): remove unused import

* refactor(api): simplify class hierarchu by removeing AtpClient

* fix(api): mock proper method for facets detection

* restore ability to restore session asynchronously

* feat(api): allow instantiating Agent with same argument as super class

* docs(api): properly extend Agent class

* style(xrpc): var name

* docs(api): remove "async" to header getter

---------

Co-authored-by: Devin Ivy <devinivy@gmail.com>
Co-authored-by: bnewbold <bnewbold@robocracy.org>
Co-authored-by: Hailey <me@haileyok.com>
2024-08-12 19:57:21 +02:00

367 lines
11 KiB
TypeScript

import { AtpAgent, ComAtprotoServerCreateAccount } from '@atproto/api'
import { DAY } from '@atproto/common'
import * as crypto from '@atproto/crypto'
import { TestNetworkNoAppView } from '@atproto/dev-env'
import { AppContext } from '../src'
import { genInvCodes } from '../src/api/com/atproto/server/util'
describe('account', () => {
let network: TestNetworkNoAppView
let ctx: AppContext
let agent: AtpAgent
beforeAll(async () => {
network = await TestNetworkNoAppView.create({
dbPostgresSchema: 'invite_codes',
pds: {
inviteRequired: true,
inviteInterval: DAY,
inviteEpoch: Date.now() - 3 * DAY,
},
})
// @ts-expect-error Error due to circular dependency with the dev-env package
ctx = network.pds.ctx
agent = network.pds.getClient()
})
afterAll(async () => {
await network.close()
})
it('describes the fact that invites are required', async () => {
const res = await agent.api.com.atproto.server.describeServer({})
expect(res.data.inviteCodeRequired).toBe(true)
})
it('succeeds with a valid code', async () => {
const code = await createInviteCode(network, agent, 1)
await createAccountWithInvite(agent, code)
})
it('fails on bad invite code', async () => {
const promise = createAccountWithInvite(agent, 'fake-invite')
await expect(promise).rejects.toThrow(
ComAtprotoServerCreateAccount.InvalidInviteCodeError,
)
})
it('fails on invite code from takendown account', async () => {
const account = await makeLoggedInAccount(network, agent)
// assign an invite code to the user
const code = await createInviteCode(network, agent, 1, account.did)
// takedown the user's account
const subject = {
$type: 'com.atproto.admin.defs#repoRef',
did: account.did,
}
await agent.api.com.atproto.admin.updateSubjectStatus(
{
subject,
takedown: { applied: true },
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
// attempt to create account with the previously generated invite code
const promise = createAccountWithInvite(agent, code)
await expect(promise).rejects.toThrow(
ComAtprotoServerCreateAccount.InvalidInviteCodeError,
)
// double check that reversing the takedown action makes the invite code valid again
await agent.api.com.atproto.admin.updateSubjectStatus(
{
subject,
takedown: { applied: false },
},
{
encoding: 'application/json',
headers: network.pds.adminAuthHeaders(),
},
)
// attempt to create account with the previously generated invite code
await createAccountWithInvite(agent, code)
})
it('fails on used up invite code', async () => {
const code = await createInviteCode(network, agent, 2)
await createAccountsWithInvite(agent, code, 2)
const promise = createAccountWithInvite(agent, code)
await expect(promise).rejects.toThrow(
ComAtprotoServerCreateAccount.InvalidInviteCodeError,
)
})
it('handles racing invite code uses', async () => {
const inviteCode = await createInviteCode(network, 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 createAccountWithInvite(agent, inviteCode)
successes++
} catch (err) {
failures++
}
}
promises.push(attempt())
}
await Promise.all(promises)
expect(successes).toBe(1)
expect(failures).toBe(9)
})
it('allow users to get available user invites', async () => {
const account = await makeLoggedInAccount(network, agent)
// no codes available yet
const res1 = await account.com.atproto.server.getAccountInviteCodes()
expect(res1.data.codes.length).toBe(0)
// next, pretend account was made 2 days in the past
const twoDaysAgo = new Date(Date.now() - 2 * DAY).toISOString()
await ctx.accountManager.db.db
.updateTable('actor')
.set({ createdAt: twoDaysAgo })
.where('did', '=', account.accountDid)
.execute()
const res2 = await account.com.atproto.server.getAccountInviteCodes()
expect(res2.data.codes.length).toBe(2)
// use both invites and confirm we can't get any more
for (const code of res2.data.codes) {
await createAccountWithInvite(agent, code.code)
}
const res3 = await account.com.atproto.server.getAccountInviteCodes()
expect(res3.data.codes.length).toBe(2)
})
it('admin gifted codes to not impact a users available codes', async () => {
const account = await makeLoggedInAccount(network, agent)
// again, pretend account was made 2 days ago
const twoDaysAgo = new Date(Date.now() - 2 * DAY).toISOString()
await ctx.accountManager.db.db
.updateTable('actor')
.set({ createdAt: twoDaysAgo })
.where('did', '=', account.accountDid)
.execute()
await createInviteCode(network, agent, 1, account.accountDid)
await createInviteCode(network, agent, 1, account.accountDid)
await createInviteCode(network, agent, 1, account.accountDid)
const res = await account.com.atproto.server.getAccountInviteCodes()
expect(res.data.codes.length).toBe(5)
const fromAdmin = res.data.codes.filter(
(code) => code.createdBy === 'admin',
)
expect(fromAdmin.length).toBe(3)
const fromSelf = res.data.codes.filter(
(code) => code.createdBy === account.accountDid,
)
expect(fromSelf.length).toBe(2)
})
it('creates invites based on epoch', async () => {
const account = await makeLoggedInAccount(network, agent)
// first, pretend account was made 2 days ago & get those two codes
const twoDaysAgo = new Date(Date.now() - 2 * DAY).toISOString()
await ctx.accountManager.db.db
.updateTable('actor')
.set({ createdAt: twoDaysAgo })
.where('did', '=', account.accountDid)
.execute()
const res1 = await account.com.atproto.server.getAccountInviteCodes()
expect(res1.data.codes.length).toBe(2)
// then pretend account was made ever so slightly over 10 days ago
const tenDaysAgo = new Date(Date.now() - 10.01 * DAY).toISOString()
await ctx.accountManager.db.db
.updateTable('actor')
.set({ createdAt: tenDaysAgo })
.where('did', '=', account.accountDid)
.execute()
// we have a 3 day epoch so should still get 3 code
const res2 = await account.com.atproto.server.getAccountInviteCodes()
expect(res2.data.codes.length).toBe(3)
// use up these codes
for (const code of res2.data.codes) {
await createAccountWithInvite(agent, code.code)
}
// we pad their account with some additional unused codes from the past which should not allow them to generate anymore
const inviteRows = genInvCodes(ctx.cfg, 10).map((code) => ({
code: code,
availableUses: 1,
disabled: 0 as const,
forAccount: account.accountDid,
createdBy: account.accountDid,
createdAt: new Date(Date.now() - 5 * DAY).toISOString(),
}))
await ctx.accountManager.db.db
.insertInto('invite_code')
.values(inviteRows)
.execute()
const res3 = await account.com.atproto.server.getAccountInviteCodes({
includeUsed: false,
})
expect(res3.data.codes.length).toBe(10)
// no we use the codes which should still not allow them to generate anymore
await ctx.accountManager.db.db
.insertInto('invite_code_use')
.values(
inviteRows.map((row) => ({
code: row.code,
usedBy: 'did:example:test',
usedAt: new Date().toISOString(),
})),
)
.execute()
const res4 = await account.com.atproto.server.getAccountInviteCodes({
includeUsed: false,
})
expect(res4.data.codes.length).toBe(0)
})
it('prevents use of disabled codes', async () => {
const first = await createInviteCode(network, agent, 1)
const account = await makeLoggedInAccount(network, agent)
const second = await createInviteCode(network, agent, 1, account.accountDid)
// disabled first by code & second by did
await agent.api.com.atproto.admin.disableInviteCodes(
{
codes: [first],
accounts: [account.accountDid],
},
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
await expect(createAccountWithInvite(agent, first)).rejects.toThrow(
ComAtprotoServerCreateAccount.InvalidInviteCodeError,
)
await expect(createAccountWithInvite(agent, 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: network.pds.adminAuthHeaders(),
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: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
expect(res.data.codes.length).toBe(3)
const fromDb = await ctx.accountManager.db.db
.selectFrom('invite_code')
.selectAll()
.where('forAccount', '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.forAccount] ??= []
dbCodesByUser[row.forAccount].push(row.code)
}
for (const { account, codes } of res.data.codes) {
expect(codes.length).toBe(2)
expect(codes.sort()).toEqual(dbCodesByUser[account].sort())
}
})
})
const createInviteCode = async (
network: TestNetworkNoAppView,
agent: AtpAgent,
uses: number,
forAccount?: string,
): Promise<string> => {
const res = await agent.api.com.atproto.server.createInviteCode(
{ useCount: uses, forAccount },
{
headers: network.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
return res.data.code
}
const createAccountWithInvite = async (agent: AtpAgent, code: string) => {
const name = crypto.randomStr(5, 'base32')
const res = await agent.api.com.atproto.server.createAccount({
email: `${name}@test.com`,
handle: `${name}.test`,
password: name,
inviteCode: code,
})
return {
...res.data,
password: name,
}
}
const createAccountsWithInvite = async (
agent: AtpAgent,
code: string,
count = 0,
) => {
for (let i = 0; i < count; i++) {
await createAccountWithInvite(agent, code)
}
}
const makeLoggedInAccount = async (
network: TestNetworkNoAppView,
inviterAgent: AtpAgent,
) => {
const code = await createInviteCode(network, inviterAgent, 1)
const account = await createAccountWithInvite(inviterAgent, code)
const agent = network.pds.getClient()
await agent.login({
identifier: account.handle,
password: account.password,
})
return agent
}