atproto/packages/pds/tests/preferences.test.ts
Matthieu Sieben 1899b1fc16
OAuth scopes (#3806)
* style: prefix `id` and `uri` with `request` where applicable

* Dynamically validate OAuth scopes

* Allow configuring trusted OAuth clients

* Improve client validation

* Rework authorization to work with permissions

* Review changes

* fix permissions

* tidy

* Drop authorization result

* unused code cleanup

* fix preferences auth

* remove redundant check in `applyWrites`

* style

* Remove need to specify "scopes" in authorized auth strategy

* fixup! Remove need to specify "scopes" in authorized auth strategy

* split authorized and oauth auth methods

* Require explicit opt-in for takendown

* fix tests

* rollback redundant permissions mechanism

* tidy

* Fix tests

* tidy

* tidy

* pr changes

* remove hack allowing access to full preferences

* always specify authorize method

* Add OAuth scope parsing & matching

* tidy

* add support for oauth scopes in client

* review changes

* Small xrpc-server optimizations

* pr comments

* Review comments

* refactor: move oauth scopes parser & checker in own package

* code simplification

* Allow multiple collections in `repo` scopes.
Allow wildcard action in `repo` scopes.
Require action in `repo` scopes.

* Rename `emailUpdate` to `email-update` in `account` scope params.
Add wildcard (`*`) in `account` and `identity` scopes.

* tidy

* add oauth-scopes package to PDS Dockerfile

* unit tests

* Syntax rework

* adapt to latest scope definition

* Add missing tests

* Render scopes in UI

* fix build

* fixes and tests

* improve ui

* tidy

* tidy

* ui improvements

* tidy

* fr messages

* tidy

* improve consent screen ui

* fix test

* tidy

* improve dx

* Remove `transition:` scopes from `scopes_supported` authorization server metadata

* Hide blob scope if no repo scope present

* changeset

* Remove the `action` param from the `identity` scope

* fix html syntax

* simplified wording

* Make `account:email` scope optional (#4089)

* Make `account:email` scope optional

* tidy

* tidy

* tidy

* tidy

* fix

* tidy

* review comments

* tidy

* refactor: remove redundant tests for identity scope parsing and matching

* minor ui fixes

* fix "back" label not translated

* ui improvements

* fix tests
2025-08-12 13:13:14 +02:00

254 lines
7.6 KiB
TypeScript

import { AtpAgent } from '@atproto/api'
import { SeedClient, TestNetworkNoAppView } from '@atproto/dev-env'
import usersSeed from './seeds/users'
describe('user preferences', () => {
let network: TestNetworkNoAppView
let agent: AtpAgent
let sc: SeedClient
let appPassHeaders: { authorization: string }
beforeAll(async () => {
network = await TestNetworkNoAppView.create({
dbPostgresSchema: 'preferences',
})
agent = network.pds.getClient()
sc = network.getSeedClient()
await usersSeed(sc)
const appPass = await network.pds.ctx.accountManager.createAppPassword(
sc.dids.alice,
'test app pass',
false,
)
const res = await agent.com.atproto.server.createSession({
identifier: sc.dids.alice,
password: appPass.password,
})
appPassHeaders = { authorization: `Bearer ${res.data.accessJwt}` }
})
afterAll(async () => {
await network.close()
})
it('requires auth to set or put preferences.', async () => {
const tryPut = agent.api.app.bsky.actor.putPreferences({
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: false },
],
})
await expect(tryPut).rejects.toThrow('Authentication Required')
const tryGet = agent.api.app.bsky.actor.getPreferences()
await expect(tryGet).rejects.toThrow('Authentication Required')
})
it('gets preferences, before any are set.', async () => {
const { data } = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
expect(data).toEqual({
preferences: [],
})
})
it('only gets preferences in app.bsky namespace.', async () => {
await network.pds.ctx.actorStore.transact(sc.dids.alice, (store) =>
store.pref.putPreferences(
[{ $type: 'com.atproto.server.defs#unknown' }],
'com.atproto',
{
hasAccessFull: true,
},
),
)
const { data } = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
expect(data).toEqual({ preferences: [] })
})
it('puts preferences, all creates.', async () => {
await agent.api.app.bsky.actor.putPreferences(
{
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: false },
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'dogs',
visibility: 'show',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'cats',
visibility: 'warn',
},
],
},
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
const { data } = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
expect(data).toEqual({
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: false },
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'dogs',
visibility: 'show',
},
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'cats',
visibility: 'warn',
},
],
})
// Ensure other prefs were not clobbered
const otherPrefs = await network.pds.ctx.actorStore.read(
sc.dids.alice,
(store) =>
store.pref.getPreferences('com.atproto', {
hasAccessFull: true,
}),
)
expect(otherPrefs).toEqual([{ $type: 'com.atproto.server.defs#unknown' }])
})
it('puts preferences, updates and removals.', async () => {
await agent.api.app.bsky.actor.putPreferences(
{
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: true },
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'dogs',
visibility: 'warn',
},
],
},
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
const { data } = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
expect(data).toEqual({
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: true },
{
$type: 'app.bsky.actor.defs#contentLabelPref',
label: 'dogs',
visibility: 'warn',
},
],
})
})
it('puts preferences, clearing them.', async () => {
await agent.api.app.bsky.actor.putPreferences(
{ preferences: [] },
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
const { data } = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
expect(data).toEqual({ preferences: [] })
})
it('fails putting preferences outside namespace.', async () => {
const tryPut = agent.api.app.bsky.actor.putPreferences(
{
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: false },
{
$type: 'com.atproto.server.defs#unknown',
// @ts-expect-error un-spec'ed prop
hello: 'world',
},
],
},
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
await expect(tryPut).rejects.toThrow(
'Some preferences are not in the app.bsky namespace',
)
})
it('fails putting preferences without $type.', async () => {
const tryPut = agent.api.app.bsky.actor.putPreferences(
{
preferences: [
{ $type: 'app.bsky.actor.defs#adultContentPref', enabled: false },
// @ts-expect-error this is what we are testing !
{
label: 'dogs',
visibility: 'warn',
},
],
},
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
await expect(tryPut).rejects.toThrow(
'Input/preferences/1 must be an object which includes the "$type" property',
)
})
it('does not read permissioned preferences with an app password', async () => {
await agent.api.app.bsky.actor.putPreferences(
{
preferences: [
{
$type: 'app.bsky.actor.defs#personalDetailsPref',
birthDate: new Date().toISOString(),
},
],
},
{ headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' },
)
const res = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: appPassHeaders },
)
expect(res.data.preferences).toEqual([])
})
it('does not write permissioned preferences with an app password', async () => {
const tryPut = agent.api.app.bsky.actor.putPreferences(
{
preferences: [
{
$type: 'app.bsky.actor.defs#personalDetailsPref',
birthDate: new Date().toISOString(),
},
],
},
{ headers: appPassHeaders, encoding: 'application/json' },
)
await expect(tryPut).rejects.toThrow(
/Do not have authorization to set preferences/,
)
})
it('does not remove permissioned preferences with an app password', async () => {
await agent.api.app.bsky.actor.putPreferences(
{
preferences: [],
},
{ headers: appPassHeaders, encoding: 'application/json' },
)
const res = await agent.api.app.bsky.actor.getPreferences(
{},
{ headers: sc.getHeaders(sc.dids.alice) },
)
const scopedPref = res.data.preferences.find(
(pref) => pref.$type === 'app.bsky.actor.defs#personalDetailsPref',
)
expect(scopedPref).toBeDefined()
})
})