* ✨ Adds safelink module * ✨ Remove createdAt timestamp bloat * :rotating_lights: Fix lint issue * 🐛 Fix pagination * 🔨 Refactor safelink rule table * 🧹 Add better default * ✨ Better search params * ✨ Remove mod requirement for query rules and events * ✨ Cleanup search for queryEvents * 📝 Add changeset * :rotating_lights: Fix lint issue * 🧹 Adjust as per review feedback * ✨ Add support for sort direction in safelink rules * :rotating_lights: Fix lint issue * ✨ Split input and response object shape * :rotating_lights: Fix lint issue
535 lines
16 KiB
TypeScript
535 lines
16 KiB
TypeScript
import { AtpAgent } from '@atproto/api'
|
|
import { SeedClient, TestNetwork, basicSeed } from '@atproto/dev-env'
|
|
import { ids } from '../src/lexicon/lexicons'
|
|
import { forSnapshot } from './_util'
|
|
|
|
describe('safelink management', () => {
|
|
let network: TestNetwork
|
|
let adminAgent: AtpAgent
|
|
let modAgent: AtpAgent
|
|
let triageAgent: AtpAgent
|
|
let sc: SeedClient
|
|
|
|
const getAdminHeaders = async (route: string) => {
|
|
return {
|
|
headers: await network.ozone.modHeaders(route, 'admin'),
|
|
}
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
network = await TestNetwork.create({
|
|
dbPostgresSchema: 'ozone_safelink_test',
|
|
})
|
|
adminAgent = network.ozone.getClient()
|
|
modAgent = network.ozone.getClient()
|
|
triageAgent = network.ozone.getClient()
|
|
sc = network.getSeedClient()
|
|
await basicSeed(sc)
|
|
await network.processAll()
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await network.close()
|
|
})
|
|
|
|
describe('addRule', () => {
|
|
const testRule = {
|
|
url: 'https://malicious-site.com',
|
|
pattern: 'domain',
|
|
action: 'block',
|
|
reason: 'phishing',
|
|
comment: 'Known phishing domain targeting users',
|
|
}
|
|
|
|
it('allows admins to add rules', async () => {
|
|
const { data: adminRule } = await adminAgent.tools.ozone.safelink.addRule(
|
|
testRule,
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
)
|
|
expect(forSnapshot(adminRule)).toMatchSnapshot()
|
|
})
|
|
|
|
it('rejects triage role from adding rules', async () => {
|
|
await expect(
|
|
triageAgent.tools.ozone.safelink.addRule(testRule, {
|
|
headers: await network.ozone.modHeaders(
|
|
ids.ToolsOzoneSafelinkAddRule,
|
|
'triage',
|
|
),
|
|
}),
|
|
).rejects.toThrow('Must be a moderator to add URL rules')
|
|
})
|
|
|
|
it('prevents duplicate rules for same URL/pattern combination', async () => {
|
|
await expect(
|
|
adminAgent.tools.ozone.safelink.addRule(
|
|
testRule,
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
),
|
|
).rejects.toThrow('A rule for this URL/domain already exists')
|
|
})
|
|
|
|
it('validates invalid pattern types', async () => {
|
|
await expect(
|
|
adminAgent.tools.ozone.safelink.addRule(
|
|
{
|
|
...testRule,
|
|
url: 'https://new-site.com',
|
|
pattern: 'invalid-pattern',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
),
|
|
).rejects.toThrow('Invalid safelink pattern type')
|
|
})
|
|
|
|
it('validates invalid action types', async () => {
|
|
await expect(
|
|
adminAgent.tools.ozone.safelink.addRule(
|
|
{
|
|
...testRule,
|
|
url: 'https://new-site2.com',
|
|
action: 'invalid-action',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
),
|
|
).rejects.toThrow('Invalid safelink action type')
|
|
})
|
|
|
|
it('validates invalid reason types', async () => {
|
|
await expect(
|
|
adminAgent.tools.ozone.safelink.addRule(
|
|
{
|
|
...testRule,
|
|
url: 'https://new-site3.com',
|
|
reason: 'invalid-reason',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
),
|
|
).rejects.toThrow('Invalid safelink reason type')
|
|
})
|
|
})
|
|
|
|
describe('updateRule', () => {
|
|
const updateTestRule = {
|
|
url: 'https://update-test.com',
|
|
pattern: 'domain',
|
|
action: 'warn',
|
|
reason: 'spam',
|
|
comment: 'Initially marked as spam',
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await adminAgent.tools.ozone.safelink.addRule(
|
|
updateTestRule,
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
)
|
|
})
|
|
|
|
it('allows updating existing rules', async () => {
|
|
const updatedData = {
|
|
url: updateTestRule.url,
|
|
pattern: updateTestRule.pattern,
|
|
action: 'block',
|
|
reason: 'phishing',
|
|
comment: 'Updated: confirmed phishing site',
|
|
}
|
|
|
|
const { data: updated } = await modAgent.tools.ozone.safelink.updateRule(
|
|
updatedData,
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkUpdateRule),
|
|
)
|
|
const { data: queried } = await modAgent.tools.ozone.safelink.queryRules(
|
|
{ urls: [updateTestRule.url] },
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules),
|
|
)
|
|
expect(updated).toMatchObject(updatedData)
|
|
expect(queried.rules[0]).toMatchObject(updatedData)
|
|
})
|
|
|
|
it('rejects triage role from updating rules', async () => {
|
|
await expect(
|
|
triageAgent.tools.ozone.safelink.updateRule(updateTestRule, {
|
|
headers: await network.ozone.modHeaders(
|
|
ids.ToolsOzoneSafelinkUpdateRule,
|
|
'triage',
|
|
),
|
|
}),
|
|
).rejects.toThrow('Must be a moderator to update URL rules')
|
|
})
|
|
|
|
it('throws error when updating non-existent rule', async () => {
|
|
await expect(
|
|
adminAgent.tools.ozone.safelink.updateRule(
|
|
{
|
|
...updateTestRule,
|
|
url: 'https://non-existent.com',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkUpdateRule),
|
|
),
|
|
).rejects.toThrow('No active rule found for this URL/domain')
|
|
})
|
|
})
|
|
|
|
describe('removeRule', () => {
|
|
const removeTestRule = {
|
|
url: 'https://remove-test.com',
|
|
pattern: 'url',
|
|
action: 'block',
|
|
reason: 'csam',
|
|
comment: 'Rule to be removed',
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await adminAgent.tools.ozone.safelink.addRule(
|
|
removeTestRule,
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
)
|
|
})
|
|
|
|
it('allows admins and moderators to remove existing rules', async () => {
|
|
const { data: removed } =
|
|
await adminAgent.tools.ozone.safelink.removeRule(
|
|
{
|
|
url: removeTestRule.url,
|
|
pattern: removeTestRule.pattern,
|
|
comment: 'Removing rule - false positive',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkRemoveRule),
|
|
)
|
|
|
|
expect(removed.eventType).toEqual('removeRule')
|
|
expect(removed.url).toEqual(removeTestRule.url)
|
|
expect(removed.comment).toEqual('Removing rule - false positive')
|
|
})
|
|
|
|
it('rejects non-moderators from removing rules', async () => {
|
|
await adminAgent.tools.ozone.safelink.addRule(
|
|
{
|
|
url: 'https://remove-test2.com',
|
|
pattern: 'domain',
|
|
action: 'block',
|
|
reason: 'spam',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
)
|
|
|
|
await expect(
|
|
triageAgent.tools.ozone.safelink.removeRule(
|
|
{
|
|
url: 'https://remove-test2.com',
|
|
pattern: 'domain',
|
|
},
|
|
{
|
|
headers: await network.ozone.modHeaders(
|
|
ids.ToolsOzoneSafelinkRemoveRule,
|
|
'triage',
|
|
),
|
|
},
|
|
),
|
|
).rejects.toThrow('Must be a moderator to remove URL rules')
|
|
})
|
|
|
|
it('throws error when removing non-existent rule', async () => {
|
|
await expect(
|
|
adminAgent.tools.ozone.safelink.removeRule(
|
|
{
|
|
url: 'https://never-existed.com',
|
|
pattern: 'domain',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkRemoveRule),
|
|
),
|
|
).rejects.toThrow('No active rule found for this URL/domain')
|
|
})
|
|
})
|
|
|
|
describe('queryRules', () => {
|
|
beforeAll(async () => {
|
|
await adminAgent.tools.ozone.safelink.addRule(
|
|
{
|
|
url: 'https://query-test1.com',
|
|
pattern: 'domain',
|
|
action: 'block',
|
|
reason: 'phishing',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
)
|
|
await adminAgent.tools.ozone.safelink.addRule(
|
|
{
|
|
url: 'https://query-test2.com/specific-path',
|
|
pattern: 'url',
|
|
action: 'warn',
|
|
reason: 'spam',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
)
|
|
})
|
|
|
|
it('allows querying all active rules', async () => {
|
|
const { data: result } = await modAgent.tools.ozone.safelink.queryRules(
|
|
{},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules),
|
|
)
|
|
|
|
expect(result.rules.length).toBeGreaterThan(0)
|
|
expect(forSnapshot(result.rules)).toMatchSnapshot()
|
|
})
|
|
|
|
it('allows filtering rules by action', async () => {
|
|
const { data: blocked } =
|
|
await adminAgent.tools.ozone.safelink.queryRules(
|
|
{
|
|
actions: ['block'],
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules),
|
|
)
|
|
|
|
const { data: warned } = await adminAgent.tools.ozone.safelink.queryRules(
|
|
{
|
|
actions: ['warn'],
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules),
|
|
)
|
|
|
|
expect(blocked.rules.every((rule) => rule.action === 'block')).toBe(true)
|
|
expect(warned.rules.every((rule) => rule.action === 'warn')).toBe(true)
|
|
})
|
|
|
|
it('allows filtering rules by reason', async () => {
|
|
const { data: phishing } =
|
|
await adminAgent.tools.ozone.safelink.queryRules(
|
|
{
|
|
reason: 'phishing',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules),
|
|
)
|
|
|
|
const { data: spam } = await adminAgent.tools.ozone.safelink.queryRules(
|
|
{
|
|
reason: 'spam',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules),
|
|
)
|
|
|
|
expect(phishing.rules.every((rule) => rule.reason === 'phishing')).toBe(
|
|
true,
|
|
)
|
|
expect(spam.rules.every((rule) => rule.reason === 'spam')).toBe(true)
|
|
})
|
|
|
|
it('allows searching by URL', async () => {
|
|
const { data: result } = await adminAgent.tools.ozone.safelink.queryRules(
|
|
{
|
|
urls: ['https://query-test1.com'],
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules),
|
|
)
|
|
|
|
expect(result.rules.length).toEqual(1)
|
|
expect(result.rules[0]?.url).toEqual('https://query-test1.com')
|
|
})
|
|
|
|
it('supports pagination', async () => {
|
|
const headers = await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules)
|
|
const { data: page1 } = await adminAgent.tools.ozone.safelink.queryRules(
|
|
{ limit: 4 },
|
|
headers,
|
|
)
|
|
|
|
expect(page1.rules.length).toEqual(4)
|
|
|
|
const { data: page2 } = await adminAgent.tools.ozone.safelink.queryRules(
|
|
{
|
|
limit: 5,
|
|
cursor: page1.cursor,
|
|
},
|
|
headers,
|
|
)
|
|
|
|
expect(page2.rules.length).toEqual(1)
|
|
})
|
|
})
|
|
|
|
describe('queryEvents', () => {
|
|
beforeAll(async () => {
|
|
await adminAgent.tools.ozone.safelink.addRule(
|
|
{
|
|
url: 'https://events-test.com',
|
|
pattern: 'domain',
|
|
action: 'warn',
|
|
reason: 'spam',
|
|
comment: 'Initial rule creation',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
)
|
|
await adminAgent.tools.ozone.safelink.updateRule(
|
|
{
|
|
url: 'https://events-test.com',
|
|
pattern: 'domain',
|
|
action: 'block',
|
|
reason: 'phishing',
|
|
comment: 'Escalated to block',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkUpdateRule),
|
|
)
|
|
})
|
|
|
|
it('allows querying safelink events', async () => {
|
|
const { data: result } = await modAgent.tools.ozone.safelink.queryEvents(
|
|
{},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryEvents),
|
|
)
|
|
|
|
expect(result.events.length).toBeGreaterThan(0)
|
|
expect(forSnapshot(result.events)).toMatchSnapshot()
|
|
})
|
|
|
|
it('allows filtering events by URL', async () => {
|
|
const { data: result } =
|
|
await adminAgent.tools.ozone.safelink.queryEvents(
|
|
{
|
|
urls: ['https://events-test.com'],
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryEvents),
|
|
)
|
|
|
|
expect(
|
|
result.events.every((event) => event.url === 'https://events-test.com'),
|
|
).toBe(true)
|
|
expect(result.events.length).toBeGreaterThanOrEqual(2)
|
|
})
|
|
|
|
it('supports pagination', async () => {
|
|
const headers = await getAdminHeaders(ids.ToolsOzoneSafelinkQueryEvents)
|
|
const { data: page1 } = await adminAgent.tools.ozone.safelink.queryEvents(
|
|
{
|
|
limit: 9,
|
|
},
|
|
headers,
|
|
)
|
|
|
|
const { data: page2 } = await adminAgent.tools.ozone.safelink.queryEvents(
|
|
{
|
|
limit: 10,
|
|
cursor: page1.cursor,
|
|
},
|
|
headers,
|
|
)
|
|
|
|
const { data: page3 } = await adminAgent.tools.ozone.safelink.queryEvents(
|
|
{
|
|
limit: 10,
|
|
cursor: page2.cursor,
|
|
},
|
|
headers,
|
|
)
|
|
|
|
expect(page1.events.length).toBeLessThanOrEqual(9)
|
|
expect(page2.events.length).toEqual(1)
|
|
expect(page3.cursor).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('event history over time', () => {
|
|
it('maintains audit trail through rule lifecycle', async () => {
|
|
const testUrl = 'https://lifecycle-test.com'
|
|
const pattern = 'domain'
|
|
|
|
await adminAgent.tools.ozone.safelink.addRule(
|
|
{
|
|
url: testUrl,
|
|
pattern,
|
|
action: 'warn',
|
|
reason: 'spam',
|
|
comment: 'Initial warning',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule),
|
|
)
|
|
|
|
await modAgent.tools.ozone.safelink.updateRule(
|
|
{
|
|
url: testUrl,
|
|
pattern,
|
|
action: 'block',
|
|
reason: 'phishing',
|
|
comment: 'Escalated to block',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkUpdateRule),
|
|
)
|
|
|
|
await adminAgent.tools.ozone.safelink.removeRule(
|
|
{
|
|
url: testUrl,
|
|
pattern,
|
|
comment: 'False positive',
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkRemoveRule),
|
|
)
|
|
|
|
const { data: events } =
|
|
await adminAgent.tools.ozone.safelink.queryEvents(
|
|
{
|
|
urls: [testUrl],
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryEvents),
|
|
)
|
|
|
|
expect(events.events.length).toEqual(3)
|
|
const eventTypes = events.events.map((e) => e.eventType).sort()
|
|
expect(eventTypes).toEqual(['addRule', 'updateRule', 'removeRule'].sort())
|
|
|
|
const { data: queryResult } =
|
|
await adminAgent.tools.ozone.safelink.queryRules(
|
|
{
|
|
urls: [testUrl],
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules),
|
|
)
|
|
expect(queryResult.rules.length).toEqual(0)
|
|
})
|
|
|
|
it('handles domain vs URL pattern precedence correctly', async () => {
|
|
const domain = 'precedence-test.com'
|
|
const specificUrl = 'https://precedence-test.com/safe-page'
|
|
const headers = await getAdminHeaders(ids.ToolsOzoneSafelinkAddRule)
|
|
|
|
await adminAgent.tools.ozone.safelink.addRule(
|
|
{
|
|
url: domain,
|
|
pattern: 'domain',
|
|
action: 'block',
|
|
reason: 'phishing',
|
|
},
|
|
headers,
|
|
)
|
|
|
|
await adminAgent.tools.ozone.safelink.addRule(
|
|
{
|
|
url: specificUrl,
|
|
pattern: 'url',
|
|
action: 'whitelist',
|
|
reason: 'none',
|
|
},
|
|
headers,
|
|
)
|
|
|
|
const { data: specificResult } =
|
|
await adminAgent.tools.ozone.safelink.queryRules(
|
|
{
|
|
urls: [specificUrl],
|
|
},
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules),
|
|
)
|
|
expect(specificResult.rules.length).toEqual(1)
|
|
expect(specificResult.rules[0]?.action).toEqual('whitelist')
|
|
|
|
const { data: domainResult } =
|
|
await adminAgent.tools.ozone.safelink.queryRules(
|
|
{ urls: [domain] },
|
|
await getAdminHeaders(ids.ToolsOzoneSafelinkQueryRules),
|
|
)
|
|
expect(domainResult.rules.length).toEqual(1)
|
|
expect(domainResult.rules[0]?.action).toEqual('block')
|
|
})
|
|
})
|
|
})
|