b934b396b1
* 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>
367 lines
11 KiB
TypeScript
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
|
|
}
|