mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 11:42:32 +09:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3276039e41 | ||
|
|
03d1354562 | ||
|
|
964845d7a7 | ||
|
|
3bc5dd8e0f | ||
|
|
17292fd6a5 | ||
|
|
f753659899 | ||
|
|
7412ec3395 | ||
|
|
570c8776c4 | ||
|
|
910db6734b |
@@ -28,9 +28,6 @@ f:media_proxy:
|
||||
f:messages:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_messages/**/*
|
||||
f:recon:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_recon/**/*
|
||||
f:snowflakes:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_snowflakes/**/*
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: build recon
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: approved
|
||||
run: echo "Build release approved."
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-recon
|
||||
dockerfile: fluxer_recon/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
Generated
-19
@@ -1868,25 +1868,6 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer-recon"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64",
|
||||
"fluxer-svc",
|
||||
"hmac 0.13.0",
|
||||
"reqwest",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer-snowflakes"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -15,7 +15,6 @@ members = [
|
||||
"fluxer_users",
|
||||
"fluxer_unfurl",
|
||||
"packages/markdown_parser/rust",
|
||||
"fluxer_recon",
|
||||
]
|
||||
exclude = [
|
||||
"packages/markdown_parser/rust/fuzz",
|
||||
|
||||
Vendored
-5
@@ -65,11 +65,6 @@ FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
|
||||
|
||||
FLUXER_RECON_MODE=observing
|
||||
FLUXER_RECON_EXPECTED_ROOMS=64
|
||||
FLUXER_RECON_WARMUP_SECONDS=15
|
||||
FLUXER_RECON_MAX_HOT_ROOMS=8
|
||||
|
||||
FLUXER_API_PORT=8080
|
||||
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
|
||||
FLUXER_API_WORKER_MODE=all_lanes
|
||||
|
||||
@@ -413,7 +413,6 @@ services:
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
|
||||
FLUXER_API_WORKER_MODE: all_lanes
|
||||
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
|
||||
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION: "true"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
|
||||
|
||||
+36
-388
@@ -933,6 +933,22 @@
|
||||
},
|
||||
"description": "Filter by target entity ID (user, channel, role, invite code, etc.)"
|
||||
},
|
||||
{
|
||||
"name": "access",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "Only return entries recorded by reads or only entries recorded by writes",
|
||||
"x-enumNames": ["read", "write"],
|
||||
"x-enumDescriptions": [
|
||||
"An entry recorded by an operation that only reads data",
|
||||
"An entry recorded by an operation that changes data or triggers work"
|
||||
],
|
||||
"enum": ["read", "write"],
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Only return entries recorded by reads or only entries recorded by writes"
|
||||
},
|
||||
{
|
||||
"name": "sort_by",
|
||||
"in": "query",
|
||||
@@ -10562,16 +10578,7 @@
|
||||
},
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"guild_activity_log_presentation": {
|
||||
"$ref": "#/components/schemas/GuildActivityLogPresentationConfigResponse"
|
||||
},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"message_hover_tracking": {"$ref": "#/components/schemas/MessageHoverTrackingConfigResponse"},
|
||||
"message_keyboard_focus": {"$ref": "#/components/schemas/MessageKeyboardFocusConfigResponse"},
|
||||
"blocked_message_groups": {"$ref": "#/components/schemas/BlockedMessageGroupsConfigResponse"},
|
||||
"expression_info_card": {"$ref": "#/components/schemas/ExpressionInfoCardConfigResponse"},
|
||||
"guild_header_collapse": {"$ref": "#/components/schemas/GuildHeaderCollapseConfigResponse"},
|
||||
"typing_indicator_rework": {"$ref": "#/components/schemas/TypingIndicatorReworkConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -10680,7 +10687,9 @@
|
||||
"logo_url": {"nullable": true, "type": "string"},
|
||||
"wordmark_url": {"nullable": true, "type": "string"},
|
||||
"favicon_url": {"nullable": true, "type": "string"},
|
||||
"theme_color": {"nullable": true, "type": "string"}
|
||||
"theme_color": {"nullable": true, "type": "string"},
|
||||
"status_page_url": {"nullable": true, "type": "string"},
|
||||
"status_page_incident_history_url": {"nullable": true, "type": "string"}
|
||||
},
|
||||
"required": [
|
||||
"product_name",
|
||||
@@ -10689,7 +10698,9 @@
|
||||
"logo_url",
|
||||
"wordmark_url",
|
||||
"favicon_url",
|
||||
"theme_color"
|
||||
"theme_color",
|
||||
"status_page_url",
|
||||
"status_page_incident_history_url"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -10974,14 +10985,7 @@
|
||||
"sso",
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"guild_activity_log_presentation",
|
||||
"experiment_delivery",
|
||||
"message_hover_tracking",
|
||||
"message_keyboard_focus",
|
||||
"blocked_message_groups",
|
||||
"expression_info_card",
|
||||
"guild_header_collapse",
|
||||
"typing_indicator_rework",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
"app_public",
|
||||
@@ -11115,38 +11119,10 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
|
||||
},
|
||||
"guild_activity_log_presentation": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/GuildActivityLogPresentationConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
},
|
||||
"message_hover_tracking": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/MessageHoverTrackingConfigUpdateRequest"}]
|
||||
},
|
||||
"message_keyboard_focus": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/MessageKeyboardFocusConfigUpdateRequest"}]
|
||||
},
|
||||
"blocked_message_groups": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/BlockedMessageGroupsConfigUpdateRequest"}]
|
||||
},
|
||||
"expression_info_card": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExpressionInfoCardConfigUpdateRequest"}]
|
||||
},
|
||||
"guild_header_collapse": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/GuildHeaderCollapseConfigUpdateRequest"}]
|
||||
},
|
||||
"typing_indicator_rework": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/TypingIndicatorReworkConfigUpdateRequest"}]
|
||||
},
|
||||
"registration": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
@@ -11188,7 +11164,9 @@
|
||||
"logo_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"wordmark_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"theme_color": {"nullable": true, "type": "string", "maxLength": 64}
|
||||
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
|
||||
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
|
||||
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048}
|
||||
}
|
||||
},
|
||||
"setup": {"nullable": true, "type": "object", "properties": {"configured": {"type": "boolean"}}},
|
||||
@@ -12665,6 +12643,16 @@
|
||||
}
|
||||
},
|
||||
"action": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||
"access": {
|
||||
"description": "Whether the recorded operation read data or changed it",
|
||||
"x-enumNames": ["read", "write"],
|
||||
"x-enumDescriptions": [
|
||||
"An entry recorded by an operation that only reads data",
|
||||
"An entry recorded by an operation that changes data or triggers work"
|
||||
],
|
||||
"enum": ["read", "write"],
|
||||
"type": "string"
|
||||
},
|
||||
"audit_log_reason": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4000},
|
||||
"metadata": {"type": "object", "additionalProperties": {"type": "string", "maxLength": 4000}},
|
||||
"created_at": {"type": "string"}
|
||||
@@ -12682,6 +12670,7 @@
|
||||
"related_guilds",
|
||||
"related_channels",
|
||||
"action",
|
||||
"access",
|
||||
"audit_log_reason",
|
||||
"metadata",
|
||||
"created_at"
|
||||
@@ -15213,114 +15202,6 @@
|
||||
"enum": ["open", "approval", "closed"],
|
||||
"type": "string"
|
||||
},
|
||||
"TypingIndicatorReworkConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"GuildHeaderCollapseConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExpressionInfoCardConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BlockedMessageGroupsConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MessageKeyboardFocusConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MessageHoverTrackingConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExperimentDeliveryConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15328,24 +15209,6 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"GuildActivityLogPresentationConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15403,186 +15266,6 @@
|
||||
"type": "string",
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
|
||||
},
|
||||
"TypingIndicatorReworkConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"default": "typing-indicator-rework-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"GuildHeaderCollapseConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"default": "guild-header-collapse-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExpressionInfoCardConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"default": "expression-info-card-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BlockedMessageGroupsConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"default": "blocked-message-groups-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MessageKeyboardFocusConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"default": "message-keyboard-focus-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MessageHoverTrackingConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {"default": "message-hover-tracking-v1", "type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ExperimentDeliveryConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15592,41 +15275,6 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"GuildActivityLogPresentationConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"default": false, "type": "boolean"},
|
||||
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
|
||||
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
|
||||
"rollout_salt": {
|
||||
"default": "guild-activity-log-presentation-v1",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"included_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"default": [],
|
||||
"maxItems": 1000,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"rollout_basis_points",
|
||||
"rollout_salt",
|
||||
"included_user_ids",
|
||||
"excluded_user_ids"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct SearchAuditLogsParams {
|
||||
pub admin_user_id: Option<String>,
|
||||
pub target_id: Option<String>,
|
||||
pub target_type: Option<String>,
|
||||
pub access: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
pub limit: u32,
|
||||
@@ -21,6 +22,12 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &SearchAuditLogsParams,
|
||||
) -> ApiResult<AuditLogsListResponse> {
|
||||
let access = params
|
||||
.access
|
||||
.as_deref()
|
||||
.map(audit_access)
|
||||
.transpose()?
|
||||
.map(|value| value.to_string());
|
||||
let sort_by = params
|
||||
.sort_by
|
||||
.as_deref()
|
||||
@@ -46,6 +53,7 @@ impl AdminApiClient {
|
||||
params.target_type.as_deref().unwrap_or_default(),
|
||||
),
|
||||
("target_id", params.target_id.as_deref().unwrap_or_default()),
|
||||
("access", access.as_deref().unwrap_or_default()),
|
||||
("sort_by", sort_by.as_deref().unwrap_or_default()),
|
||||
("sort_order", sort_order.as_deref().unwrap_or_default()),
|
||||
("limit", limit.as_str()),
|
||||
@@ -55,6 +63,11 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_access(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsAccess> {
|
||||
generated_types::ListAdminAuditLogsAccess::try_from(value)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
|
||||
let value = match value {
|
||||
"created_at" => "createdAt",
|
||||
@@ -83,6 +96,13 @@ mod tests {
|
||||
assert_eq!(audit_sort_order("desc").unwrap().to_string(), "desc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_only_known_access_filters() {
|
||||
assert_eq!(audit_access("read").unwrap().to_string(), "read");
|
||||
assert_eq!(audit_access("write").unwrap().to_string(), "write");
|
||||
assert!(audit_access("all").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_lossy_audit_totals() {
|
||||
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
|
||||
@@ -99,6 +119,7 @@ mod tests {
|
||||
"admin_user_id": "234567890123456789",
|
||||
"admin_user": null,
|
||||
"action": "USER_UPDATE",
|
||||
"access": "write",
|
||||
"target_id": "345678901234567890",
|
||||
"target_type": "user",
|
||||
"target_user": null,
|
||||
@@ -118,6 +139,26 @@ mod tests {
|
||||
assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE");
|
||||
|
||||
let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap();
|
||||
assert_eq!(response.logs[0].access.as_deref(), Some("write"));
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_audit_entries_from_an_api_without_access() {
|
||||
let json = serde_json::json!({
|
||||
"logs": [{
|
||||
"log_id": "123456789012345678",
|
||||
"admin_user_id": "234567890123456789",
|
||||
"action": "USER_UPDATE",
|
||||
"target_id": "345678901234567890",
|
||||
"target_type": "user",
|
||||
"audit_log_reason": null,
|
||||
"metadata": {},
|
||||
"created_at": "2026-09-11T12:00:00.000Z"
|
||||
}],
|
||||
"total": 1
|
||||
});
|
||||
let response: AuditLogsListResponse = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(response.logs[0].access, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ pub struct AuditLogEntry {
|
||||
#[serde(default)]
|
||||
pub admin_user: Option<AuditLogUserSummary>,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub access: Option<String>,
|
||||
pub target_id: String,
|
||||
pub target_type: String,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -24,20 +24,6 @@ pub struct InstanceConfigResponse {
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub message_hover_tracking: MessageHoverTrackingConfigResponse,
|
||||
#[serde(default)]
|
||||
pub message_keyboard_focus: MessageKeyboardFocusConfigResponse,
|
||||
#[serde(default)]
|
||||
pub blocked_message_groups: BlockedMessageGroupsConfigResponse,
|
||||
#[serde(default)]
|
||||
pub guild_activity_log_presentation: GuildActivityLogPresentationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub expression_info_card: ExpressionInfoCardConfigResponse,
|
||||
#[serde(default)]
|
||||
pub guild_header_collapse: GuildHeaderCollapseConfigResponse,
|
||||
#[serde(default)]
|
||||
pub typing_indicator_rework: TypingIndicatorReworkConfigResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -340,6 +326,8 @@ pub struct AppBrandingConfigResponse {
|
||||
pub wordmark_url: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
pub theme_color: Option<String>,
|
||||
pub status_page_url: Option<String>,
|
||||
pub status_page_incident_history_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppBrandingConfigResponse {
|
||||
@@ -352,6 +340,8 @@ impl Default for AppBrandingConfigResponse {
|
||||
wordmark_url: None,
|
||||
favicon_url: None,
|
||||
theme_color: None,
|
||||
status_page_url: None,
|
||||
status_page_incident_history_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -551,272 +541,6 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct MessageHoverTrackingConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for MessageHoverTrackingConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "message-hover-tracking-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct MessageHoverTrackingConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct MessageKeyboardFocusConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for MessageKeyboardFocusConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "message-keyboard-focus-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct MessageKeyboardFocusConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct BlockedMessageGroupsConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for BlockedMessageGroupsConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "blocked-message-groups-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct BlockedMessageGroupsConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct GuildActivityLogPresentationConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for GuildActivityLogPresentationConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "guild-activity-log-presentation-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct GuildActivityLogPresentationConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExpressionInfoCardConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ExpressionInfoCardConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "expression-info-card-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ExpressionInfoCardConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct GuildHeaderCollapseConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for GuildHeaderCollapseConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "guild-header-collapse-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct GuildHeaderCollapseConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct TypingIndicatorReworkConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for TypingIndicatorReworkConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "typing-indicator-rework-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct TypingIndicatorReworkConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
@@ -934,20 +658,6 @@ pub struct InstanceConfigUpdateRequest {
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_hover_tracking: Option<MessageHoverTrackingConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_keyboard_focus: Option<MessageKeyboardFocusConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocked_message_groups: Option<BlockedMessageGroupsConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_activity_log_presentation: Option<GuildActivityLogPresentationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expression_info_card: Option<ExpressionInfoCardConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_header_collapse: Option<GuildHeaderCollapseConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub typing_indicator_rework: Option<TypingIndicatorReworkConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -1152,6 +862,10 @@ pub struct AppBrandingConfigUpdateRequest {
|
||||
pub favicon_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme_color: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_page_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_page_incident_history_url: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -1282,62 +996,12 @@ mod tests {
|
||||
.expect("default noise config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let hover = serde_json::from_value::<MessageHoverTrackingConfigResponse>(json!({}))
|
||||
.expect("default message hover tracking config");
|
||||
let keyboard = serde_json::from_value::<MessageKeyboardFocusConfigResponse>(json!({}))
|
||||
.expect("default message keyboard focus config");
|
||||
let blocked = serde_json::from_value::<BlockedMessageGroupsConfigResponse>(json!({}))
|
||||
.expect("default blocked message groups config");
|
||||
let activity_log =
|
||||
serde_json::from_value::<GuildActivityLogPresentationConfigResponse>(json!({}))
|
||||
.expect("default guild activity log presentation config");
|
||||
let expression = serde_json::from_value::<ExpressionInfoCardConfigResponse>(json!({}))
|
||||
.expect("default expression info card config");
|
||||
let collapse = serde_json::from_value::<GuildHeaderCollapseConfigResponse>(json!({}))
|
||||
.expect("default guild header collapse config");
|
||||
let typing = serde_json::from_value::<TypingIndicatorReworkConfigResponse>(json!({}))
|
||||
.expect("default typing indicator rework config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let hover =
|
||||
serde_json::to_value(hover).expect("serializable message hover tracking config");
|
||||
let keyboard =
|
||||
serde_json::to_value(keyboard).expect("serializable message keyboard focus config");
|
||||
let blocked =
|
||||
serde_json::to_value(blocked).expect("serializable blocked message groups config");
|
||||
let activity_log = serde_json::to_value(activity_log)
|
||||
.expect("serializable guild activity log presentation config");
|
||||
let expression =
|
||||
serde_json::to_value(expression).expect("serializable expression info card config");
|
||||
let collapse =
|
||||
serde_json::to_value(collapse).expect("serializable guild header collapse config");
|
||||
let typing =
|
||||
serde_json::to_value(typing).expect("serializable typing indicator rework config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
let generated_hover: generated_types::MessageHoverTrackingConfigResponse =
|
||||
serde_json::from_value(hover.clone())
|
||||
.expect("generated message hover tracking config contract");
|
||||
let generated_keyboard: generated_types::MessageKeyboardFocusConfigResponse =
|
||||
serde_json::from_value(keyboard.clone())
|
||||
.expect("generated message keyboard focus config contract");
|
||||
let generated_blocked: generated_types::BlockedMessageGroupsConfigResponse =
|
||||
serde_json::from_value(blocked.clone())
|
||||
.expect("generated blocked message groups config contract");
|
||||
let generated_activity_log: generated_types::GuildActivityLogPresentationConfigResponse =
|
||||
serde_json::from_value(activity_log.clone())
|
||||
.expect("generated guild activity log presentation config contract");
|
||||
let generated_expression: generated_types::ExpressionInfoCardConfigResponse =
|
||||
serde_json::from_value(expression.clone())
|
||||
.expect("generated expression info card config contract");
|
||||
let generated_collapse: generated_types::GuildHeaderCollapseConfigResponse =
|
||||
serde_json::from_value(collapse.clone())
|
||||
.expect("generated guild header collapse config contract");
|
||||
let generated_typing: generated_types::TypingIndicatorReworkConfigResponse =
|
||||
serde_json::from_value(typing.clone())
|
||||
.expect("generated typing indicator rework config contract");
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||
noise
|
||||
@@ -1347,51 +1011,9 @@ mod tests {
|
||||
.expect("serializable generated delivery config"),
|
||||
delivery
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_hover)
|
||||
.expect("serializable generated message hover tracking config"),
|
||||
hover
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_keyboard)
|
||||
.expect("serializable generated message keyboard focus config"),
|
||||
keyboard
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_blocked)
|
||||
.expect("serializable generated blocked message groups config"),
|
||||
blocked
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_activity_log)
|
||||
.expect("serializable generated guild activity log presentation config"),
|
||||
activity_log
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_expression)
|
||||
.expect("serializable generated expression info card config"),
|
||||
expression
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_collapse)
|
||||
.expect("serializable generated guild header collapse config"),
|
||||
collapse
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_typing)
|
||||
.expect("serializable generated typing indicator rework config"),
|
||||
typing
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
("MessageHoverTrackingConfigResponse", hover),
|
||||
("MessageKeyboardFocusConfigResponse", keyboard),
|
||||
("BlockedMessageGroupsConfigResponse", blocked),
|
||||
("GuildActivityLogPresentationConfigResponse", activity_log),
|
||||
("ExpressionInfoCardConfigResponse", expression),
|
||||
("GuildHeaderCollapseConfigResponse", collapse),
|
||||
("TypingIndicatorReworkConfigResponse", typing),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
assert_eq!(
|
||||
@@ -1402,231 +1024,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_client_accepts_unknown_response_fields() {
|
||||
const GENERATED_CLIENT: &str =
|
||||
include_str!(concat!(env!("OUT_DIR"), "/admin_api_generated.rs"));
|
||||
assert!(
|
||||
!GENERATED_CLIENT.contains("deny_unknown_fields"),
|
||||
"fluxer_admin/build.rs must clear additionalProperties so a new API field cannot \
|
||||
blank an admin page"
|
||||
);
|
||||
let mut section = serde_json::to_value(ExpressionInfoCardConfigResponse::default())
|
||||
.expect("serializable expression info card config");
|
||||
section
|
||||
.as_object_mut()
|
||||
.expect("expression info card object")
|
||||
.insert("future_knob".to_owned(), json!(7));
|
||||
serde_json::from_value::<generated_types::ExpressionInfoCardConfigResponse>(section)
|
||||
.expect("generated instance config section tolerates unknown fields");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_audit_log_change_accepts_scalar_and_object_values() {
|
||||
for value in [json!("old"), json!(7), json!(true), json!(null)] {
|
||||
let change = serde_json::from_value::<generated_types::AuditLogChangeSchema>(
|
||||
json!({"key": "name", "old_value": value, "new_value": {"added": [], "removed": []}}),
|
||||
)
|
||||
.expect("generated audit log change tolerates scalar values");
|
||||
assert_eq!(change.key, "name");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expression_info_card_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = ExpressionInfoCardConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::ExpressionInfoCardConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ExpressionInfoCardConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_header_collapse_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = GuildHeaderCollapseConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::GuildHeaderCollapseConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(GuildHeaderCollapseConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_header_collapse_response_defaults_to_the_guild_header_collapse_v1_salt() {
|
||||
let config = GuildHeaderCollapseConfigResponse::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.config_version, 0);
|
||||
assert_eq!(config.rollout_basis_points, 0);
|
||||
assert_eq!(config.rollout_salt, "guild-header-collapse-v1");
|
||||
assert!(config.included_user_ids.is_empty());
|
||||
assert!(config.excluded_user_ids.is_empty());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<GuildHeaderCollapseConfigResponse>(json!({}))
|
||||
.expect("default guild header collapse config")
|
||||
.rollout_salt,
|
||||
"guild-header-collapse-v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_indicator_rework_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = TypingIndicatorReworkConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::TypingIndicatorReworkConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(TypingIndicatorReworkConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_indicator_rework_response_defaults_to_the_typing_indicator_rework_v1_salt() {
|
||||
let config = TypingIndicatorReworkConfigResponse::default();
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.config_version, 0);
|
||||
assert_eq!(config.rollout_basis_points, 0);
|
||||
assert_eq!(config.rollout_salt, "typing-indicator-rework-v1");
|
||||
assert!(config.included_user_ids.is_empty());
|
||||
assert!(config.excluded_user_ids.is_empty());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<TypingIndicatorReworkConfigResponse>(json!({}))
|
||||
.expect("default typing indicator rework config")
|
||||
.rollout_salt,
|
||||
"typing-indicator-rework-v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_hover_tracking_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = MessageHoverTrackingConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::MessageHoverTrackingConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(MessageHoverTrackingConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_keyboard_focus_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = MessageKeyboardFocusConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::MessageKeyboardFocusConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(MessageKeyboardFocusConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_message_groups_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = BlockedMessageGroupsConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::BlockedMessageGroupsConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(BlockedMessageGroupsConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_activity_log_presentation_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = GuildActivityLogPresentationConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::GuildActivityLogPresentationConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(GuildActivityLogPresentationConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
|
||||
@@ -121,6 +121,7 @@ pub async fn render(
|
||||
admin_user_id: None,
|
||||
target_id: Some(guild_id.to_owned()),
|
||||
target_type: Some("guild".to_owned()),
|
||||
access: Some("write".to_owned()),
|
||||
sort_by: Some("created_at".to_owned()),
|
||||
sort_order: Some("desc".to_owned()),
|
||||
limit: 50,
|
||||
@@ -134,7 +135,7 @@ pub async fn render(
|
||||
@if let Some(resp) = resp {
|
||||
@if resp.logs.is_empty() {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"No admin audit log entries for this guild."
|
||||
"No admin write actions have been recorded for this guild."
|
||||
}
|
||||
} @else {
|
||||
(table_container(table(maud::html! {
|
||||
|
||||
@@ -28,6 +28,7 @@ struct AuditLogsQuery {
|
||||
admin_user_id: Option<String>,
|
||||
target_id: Option<String>,
|
||||
target_type: Option<String>,
|
||||
access: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
sort_order: Option<String>,
|
||||
limit: Option<u32>,
|
||||
@@ -119,6 +120,7 @@ async fn audit_logs_page(
|
||||
admin_user_id: query.admin_user_id.as_deref().unwrap_or(""),
|
||||
target_id: query.target_id.as_deref().unwrap_or(""),
|
||||
target_type: query.target_type.as_deref().unwrap_or(""),
|
||||
access: query.access.as_deref().unwrap_or(""),
|
||||
sort_by: query.sort_by.as_deref().unwrap_or("createdAt"),
|
||||
sort_order: query.sort_order.as_deref().unwrap_or("desc"),
|
||||
limit,
|
||||
@@ -131,6 +133,7 @@ async fn audit_logs_page(
|
||||
admin_user_id: nonempty(params.admin_user_id),
|
||||
target_id: nonempty(params.target_id),
|
||||
target_type: nonempty(params.target_type),
|
||||
access: nonempty(params.access),
|
||||
sort_by: Some(params.sort_by.to_owned()),
|
||||
sort_order: Some(params.sort_order.to_owned()),
|
||||
limit,
|
||||
|
||||
@@ -6,24 +6,20 @@ use crate::{
|
||||
types::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, BlockedMessageGroupsConfigUpdateRequest,
|
||||
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
|
||||
ExperimentDeliveryConfigUpdateRequest, ExpressionInfoCardConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
GuildActivityLogPresentationConfigUpdateRequest,
|
||||
GuildHeaderCollapseConfigUpdateRequest, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters,
|
||||
MessageHoverTrackingConfigUpdateRequest, MessageKeyboardFocusConfigUpdateRequest,
|
||||
NoiseSuppressionBackend, PremiumMode, RegistrationMode, SsoConfigUpdateRequest,
|
||||
TypingIndicatorReworkConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||
VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
VoiceNoiseSuppressionGuildOverride,
|
||||
},
|
||||
},
|
||||
@@ -211,36 +207,6 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_message_hover_tracking" => match build_message_hover_tracking_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_message_keyboard_focus" => match build_message_keyboard_focus_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_blocked_message_groups" => match build_blocked_message_groups_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_guild_activity_log_presentation" => {
|
||||
match build_guild_activity_log_presentation_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
}
|
||||
}
|
||||
"update_expression_info_card" => match build_expression_info_card_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_guild_header_collapse" => match build_guild_header_collapse_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_typing_indicator_rework" => match build_typing_indicator_rework_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -481,10 +447,10 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
|
||||
}
|
||||
}
|
||||
|
||||
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
|
||||
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
||||
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
|
||||
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
|
||||
@@ -510,36 +476,35 @@ where
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn parse_experiment_rollout_salt(
|
||||
fn parse_voice_noise_suppression_rollout_salt(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(raw) = form.first(key) else {
|
||||
let Some(raw) = form.first("voice_ns_rollout_salt") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let salt = raw.trim();
|
||||
if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS {
|
||||
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
|
||||
return Err(format!(
|
||||
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
));
|
||||
}
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn is_experiment_snowflake(value: &str) -> bool {
|
||||
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
|
||||
let candidate = candidate.trim();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_experiment_snowflake(candidate) {
|
||||
if !is_voice_noise_suppression_snowflake(candidate) {
|
||||
return Err(format!(
|
||||
"{label} entry {} must contain 1 to 20 decimal digits",
|
||||
index + 1
|
||||
@@ -571,7 +536,7 @@ fn parse_voice_noise_suppression_guild_overrides(
|
||||
format!("Guild overrides line {line_number} must use guild_id=backend")
|
||||
})?;
|
||||
let guild_id = guild_id.trim();
|
||||
if !is_experiment_snowflake(guild_id) {
|
||||
if !is_voice_noise_suppression_snowflake(guild_id) {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
|
||||
));
|
||||
@@ -637,14 +602,14 @@ fn build_voice_noise_suppression_update(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
|
||||
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
@@ -664,221 +629,6 @@ fn build_voice_noise_suppression_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_message_hover_tracking_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
message_hover_tracking: Some(MessageHoverTrackingConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("message_hover_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"message_hover_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "message_hover_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("message_hover_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("message_hover_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_message_keyboard_focus_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
message_keyboard_focus: Some(MessageKeyboardFocusConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("message_keyboard_focus_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"message_keyboard_focus_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(
|
||||
form,
|
||||
"message_keyboard_focus_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("message_keyboard_focus_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("message_keyboard_focus_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_blocked_message_groups_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
blocked_message_groups: Some(BlockedMessageGroupsConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("blocked_groups_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"blocked_groups_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "blocked_groups_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("blocked_groups_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("blocked_groups_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_guild_activity_log_presentation_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
guild_activity_log_presentation: Some(GuildActivityLogPresentationConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("guild_activity_log_presentation_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"guild_activity_log_presentation_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(
|
||||
form,
|
||||
"guild_activity_log_presentation_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("guild_activity_log_presentation_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("guild_activity_log_presentation_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_expression_info_card_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
expression_info_card: Some(ExpressionInfoCardConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("expression_card_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"expression_card_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "expression_card_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("expression_card_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("expression_card_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_guild_header_collapse_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
guild_header_collapse: Some(GuildHeaderCollapseConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("guild_header_collapse_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"guild_header_collapse_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(
|
||||
form,
|
||||
"guild_header_collapse_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("guild_header_collapse_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("guild_header_collapse_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_typing_indicator_rework_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
typing_indicator_rework: Some(TypingIndicatorReworkConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("typing_indicator_rework_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"typing_indicator_rework_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(
|
||||
form,
|
||||
"typing_indicator_rework_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("typing_indicator_rework_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("typing_indicator_rework_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -933,6 +683,8 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
wordmark_url: optional("app_wordmark_url"),
|
||||
favicon_url: optional("app_favicon_url"),
|
||||
theme_color: optional("app_theme_color"),
|
||||
status_page_url: optional("app_status_page_url"),
|
||||
status_page_incident_history_url: optional("app_status_page_incident_history_url"),
|
||||
}),
|
||||
setup: Some(AppSetupConfigUpdateRequest {
|
||||
configured: Some(form.bool_value("app_setup_configured")),
|
||||
@@ -1555,9 +1307,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
@@ -1570,15 +1322,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_dedupes_preserving_order() {
|
||||
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
|
||||
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
@@ -1588,8 +1341,11 @@ mod tests {
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
|
||||
.expect_err("invalid ID"),
|
||||
parse_voice_noise_suppression_user_ids(
|
||||
&format!("123,{value}"),
|
||||
"Included user IDs"
|
||||
)
|
||||
.expect_err("invalid ID"),
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{value}"
|
||||
);
|
||||
@@ -1597,17 +1353,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
let ids =
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
.expect_err("too many IDs"),
|
||||
"Included user IDs must contain at most 1000 unique IDs"
|
||||
);
|
||||
@@ -1800,152 +1557,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_guild_header_collapse_update_reads_the_whole_form() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"guild_header_collapse_enabled=true&guild_header_collapse_rollout_basis_points=2500&guild_header_collapse_rollout_salt=%20guild-header-collapse-v2%20&guild_header_collapse_included_user_ids=1500000000000000001%0A1500000000000000001&guild_header_collapse_excluded_user_ids=1500000000000000002%2C%201500000000000000003",
|
||||
);
|
||||
let update = build_guild_header_collapse_update(&form)
|
||||
.expect("valid form")
|
||||
.guild_header_collapse
|
||||
.expect("guild header collapse update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(2_500));
|
||||
assert_eq!(
|
||||
update.rollout_salt,
|
||||
Some("guild-header-collapse-v2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_guild_header_collapse_update_leaves_the_rollout_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_guild_header_collapse_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"guild_header_collapse": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_guild_header_collapse_update_rejects_a_rollout_above_the_maximum() {
|
||||
let form = MultiValueForm::parse(b"guild_header_collapse_rollout_basis_points=10001");
|
||||
assert_eq!(
|
||||
build_guild_header_collapse_update(&form).expect_err("invalid rollout"),
|
||||
"Rollout basis points must be a whole number between 0 and 10000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_guild_header_collapse_update_reads_only_its_own_prefix() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"guild_header_collapse_enabled=true&guild_header_collapse_rollout_basis_points=2500&guild_header_collapse_rollout_salt=guild-header-collapse-v2&guild_header_collapse_included_user_ids=1500000000000000001&expression_card_rollout_basis_points=750&expression_card_rollout_salt=expression-info-card-v2&expression_card_excluded_user_ids=1500000000000000009",
|
||||
);
|
||||
let update = build_guild_header_collapse_update(&form)
|
||||
.expect("valid form")
|
||||
.guild_header_collapse
|
||||
.expect("guild header collapse update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(2_500));
|
||||
assert_eq!(
|
||||
update.rollout_salt,
|
||||
Some("guild-header-collapse-v2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(update.excluded_user_ids, Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_typing_indicator_rework_update_reads_the_whole_form() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"typing_indicator_rework_enabled=true&typing_indicator_rework_rollout_basis_points=2500&typing_indicator_rework_rollout_salt=%20typing-indicator-rework-v2%20&typing_indicator_rework_included_user_ids=1500000000000000001%0A1500000000000000001&typing_indicator_rework_excluded_user_ids=1500000000000000002%2C%201500000000000000003",
|
||||
);
|
||||
let update = build_typing_indicator_rework_update(&form)
|
||||
.expect("valid form")
|
||||
.typing_indicator_rework
|
||||
.expect("typing indicator rework update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(2_500));
|
||||
assert_eq!(
|
||||
update.rollout_salt,
|
||||
Some("typing-indicator-rework-v2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_typing_indicator_rework_update_leaves_the_rollout_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_typing_indicator_rework_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"typing_indicator_rework": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_typing_indicator_rework_update_rejects_a_rollout_above_the_maximum() {
|
||||
let form = MultiValueForm::parse(b"typing_indicator_rework_rollout_basis_points=10001");
|
||||
assert_eq!(
|
||||
build_typing_indicator_rework_update(&form).expect_err("invalid rollout"),
|
||||
"Rollout basis points must be a whole number between 0 and 10000"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_typing_indicator_rework_update_reads_only_its_own_prefix() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"expression_card_enabled=true&expression_card_rollout_basis_points=2500&expression_card_rollout_salt=expression-info-card-v2&expression_card_included_user_ids=1500000000000000001&typing_indicator_rework_rollout_basis_points=750&typing_indicator_rework_rollout_salt=typing-indicator-rework-v2&typing_indicator_rework_excluded_user_ids=1500000000000000009",
|
||||
);
|
||||
let update = build_typing_indicator_rework_update(&form)
|
||||
.expect("valid form")
|
||||
.typing_indicator_rework
|
||||
.expect("typing indicator rework update");
|
||||
assert_eq!(update.enabled, Some(false));
|
||||
assert_eq!(update.rollout_basis_points, Some(750));
|
||||
assert_eq!(
|
||||
update.rollout_salt,
|
||||
Some("typing-indicator-rework-v2".to_owned())
|
||||
);
|
||||
assert_eq!(update.included_user_ids, Some(Vec::new()));
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000009".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -245,6 +245,7 @@ pub async fn render(
|
||||
admin_user_id: None,
|
||||
target_id: Some(user_id.to_owned()),
|
||||
target_type: None,
|
||||
access: Some("write".to_owned()),
|
||||
sort_by: Some("created_at".to_owned()),
|
||||
sort_order: Some("desc".to_owned()),
|
||||
limit,
|
||||
|
||||
@@ -72,7 +72,7 @@ pub fn audit_logs_for_target(
|
||||
let total_pages = total.div_ceil(u64::from(PAGE_SIZE)).max(1);
|
||||
let page_number = u64::from(current_page) + 1;
|
||||
let all_logs_href = format!(
|
||||
"{base_path}/audit-logs?target_id={}",
|
||||
"{base_path}/audit-logs?target_id={}&access=write",
|
||||
urlencoding::encode(target_id)
|
||||
);
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn audit_logs_for_target(
|
||||
}
|
||||
}
|
||||
@if entries.is_empty() {
|
||||
(empty_state("No admin actions have been recorded against this entity."))
|
||||
(empty_state("No admin write actions have been recorded against this entity."))
|
||||
} @else {
|
||||
(table_container(html! {
|
||||
(table(html! {
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct AuditLogsParams<'a> {
|
||||
pub admin_user_id: &'a str,
|
||||
pub target_id: &'a str,
|
||||
pub target_type: &'a str,
|
||||
pub access: &'a str,
|
||||
pub sort_by: &'a str,
|
||||
pub sort_order: &'a str,
|
||||
pub limit: u32,
|
||||
@@ -42,6 +43,11 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
||||
("file_sha", "File SHA"),
|
||||
("email", "Email"),
|
||||
];
|
||||
let access_options: &[(&str, &str)] = &[
|
||||
("", "All entries"),
|
||||
("write", "Writes only"),
|
||||
("read", "Reads only"),
|
||||
];
|
||||
let sort_options: &[(&str, &str)] = &[("createdAt", "Created at"), ("relevance", "Relevance")];
|
||||
let order_options: &[(&str, &str)] = &[("desc", "Newest first"), ("asc", "Oldest first")];
|
||||
let limit_options: &[(&str, &str)] =
|
||||
@@ -60,6 +66,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
||||
"Filter by admin user ID..."))
|
||||
(select_input("target_type", "Target type",
|
||||
target_type_options, params.target_type))
|
||||
(select_input("access", "Access", access_options, params.access))
|
||||
(select_input("sort_by", "Sort by", sort_options, params.sort_by))
|
||||
(select_input("sort_order", "Order", order_options, params.sort_order))
|
||||
(select_input("limit", "Page size", limit_options, &limit_str))
|
||||
@@ -81,6 +88,7 @@ fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) ->
|
||||
("admin_user_id", params.admin_user_id),
|
||||
("target_id", params.target_id),
|
||||
("target_type", params.target_type),
|
||||
("access", params.access),
|
||||
("sort_by", params.sort_by),
|
||||
("sort_order", params.sort_order),
|
||||
]
|
||||
@@ -169,6 +177,7 @@ mod tests {
|
||||
admin_user_id: "",
|
||||
target_id: "",
|
||||
target_type: "bulk_job",
|
||||
access: "",
|
||||
sort_by: "createdAt",
|
||||
sort_order: "desc",
|
||||
limit: 50,
|
||||
@@ -176,5 +185,28 @@ mod tests {
|
||||
};
|
||||
let markup = filters_section("/admin", ¶ms).into_string();
|
||||
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
|
||||
assert!(markup.contains(r#"<option value="" selected>All entries</option>"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_filter_survives_form_and_pagination() {
|
||||
let params = AuditLogsParams {
|
||||
query: "",
|
||||
admin_user_id: "",
|
||||
target_id: "1500000000000000001",
|
||||
target_type: "",
|
||||
access: "read",
|
||||
sort_by: "createdAt",
|
||||
sort_order: "desc",
|
||||
limit: 50,
|
||||
current_page: 0,
|
||||
};
|
||||
let markup = filters_section("/admin", ¶ms).into_string();
|
||||
assert!(markup.contains(r#"<select id="access" name="access""#));
|
||||
assert!(markup.contains(r#"<option value="read" selected>Reads only</option>"#));
|
||||
assert_eq!(
|
||||
build_pagination_url("/admin", 1, ¶ms),
|
||||
"/admin/audit-logs?page=1&target_id=1500000000000000001&access=read&sort_by=createdAt&sort_order=desc&limit=50"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, BlockedMessageGroupsConfigResponse,
|
||||
ExperimentDeliveryConfigResponse, ExpressionInfoCardConfigResponse,
|
||||
GatewayRolloutConfigResponse, GuildActivityLogPresentationConfigResponse,
|
||||
GuildHeaderCollapseConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, MessageHoverTrackingConfigResponse,
|
||||
MessageKeyboardFocusConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse, TypingIndicatorReworkConfigResponse,
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
|
||||
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
|
||||
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
|
||||
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -152,13 +148,6 @@ pub fn instance_config_page(
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(message_hover_tracking_section(base, csrf_token, &instance_config.message_hover_tracking))
|
||||
(message_keyboard_focus_section(base, csrf_token, &instance_config.message_keyboard_focus))
|
||||
(blocked_message_groups_section(base, csrf_token, &instance_config.blocked_message_groups))
|
||||
(guild_activity_log_presentation_section(base, csrf_token, &instance_config.guild_activity_log_presentation))
|
||||
(expression_info_card_section(base, csrf_token, &instance_config.expression_info_card))
|
||||
(guild_header_collapse_section(base, csrf_token, &instance_config.guild_header_collapse))
|
||||
(typing_indicator_rework_section(base, csrf_token, &instance_config.typing_indicator_rework))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -859,6 +848,18 @@ fn app_public_config_section(
|
||||
app_public.branding.favicon_url.as_deref().unwrap_or(""),
|
||||
"https://example.com/favicon.ico",
|
||||
))
|
||||
(text_input(
|
||||
"app_status_page_url",
|
||||
"Status page URL",
|
||||
app_public.branding.status_page_url.as_deref().unwrap_or(""),
|
||||
"https://fluxerstatus.com",
|
||||
))
|
||||
(text_input(
|
||||
"app_status_page_incident_history_url",
|
||||
"Status page history URL",
|
||||
app_public.branding.status_page_incident_history_url.as_deref().unwrap_or(""),
|
||||
"https://fluxerstatus.com/history",
|
||||
))
|
||||
}
|
||||
div class="space-y-2" {
|
||||
(checkbox(
|
||||
@@ -1186,733 +1187,6 @@ fn voice_noise_suppression_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn message_hover_tracking_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
message_hover_tracking: &MessageHoverTrackingConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if message_hover_tracking.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = message_hover_tracking.included_user_ids.join("\n");
|
||||
let excluded_user_ids = message_hover_tracking.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Message Hover Tracking",
|
||||
"Picks which message hover implementation targeted clients run in the message list. A \
|
||||
targeted client resolves the hovered message from one shared pointer oracle and drives \
|
||||
the message action bar from that state. While the master switch below is off every \
|
||||
client keeps the per-row implementation it ships with, whatever the rest of these \
|
||||
fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_message_hover_tracking"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (message_hover_tracking.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"message_hover_enabled",
|
||||
"true",
|
||||
"Serve message hover tracking assignments to clients",
|
||||
message_hover_tracking.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
rollout is inert and keeps its current hover behavior, so the rollout \
|
||||
and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"message_hover_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&message_hover_tracking.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"message_hover_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&message_hover_tracking.rollout_salt,
|
||||
"message-hover-tracking-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"message_hover_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save; blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"message_hover_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Message Hover Tracking Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn message_keyboard_focus_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
message_keyboard_focus: &MessageKeyboardFocusConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if message_keyboard_focus.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = message_keyboard_focus.included_user_ids.join("\n");
|
||||
let excluded_user_ids = message_keyboard_focus.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Message Keyboard Focus",
|
||||
"Picks whether targeted clients run the keyboard navigation rework in the message list. \
|
||||
A targeted client reaches the message list from the composer with one Tab, walks \
|
||||
messages with the arrow keys through revealed blocked groups, and draws the focus ring \
|
||||
inside each row. While the master switch below is off every client keeps the keyboard \
|
||||
navigation it ships with, whatever the rest of these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_message_keyboard_focus"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (message_keyboard_focus.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"message_keyboard_focus_enabled",
|
||||
"true",
|
||||
"Serve message keyboard focus assignments to clients",
|
||||
message_keyboard_focus.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
rollout is inert and keeps its current keyboard navigation, so the rollout \
|
||||
and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"message_keyboard_focus_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&message_keyboard_focus.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"message_keyboard_focus_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&message_keyboard_focus.rollout_salt,
|
||||
"message-keyboard-focus-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"message_keyboard_focus_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save; blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"message_keyboard_focus_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Message Keyboard Focus Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn blocked_message_groups_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
blocked_message_groups: &BlockedMessageGroupsConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if blocked_message_groups.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = blocked_message_groups.included_user_ids.join("\n");
|
||||
let excluded_user_ids = blocked_message_groups.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Blocked Message Groups",
|
||||
"Picks how targeted clients render a revealed block of blocked or suspected spam \
|
||||
messages. A targeted client draws the block full width, spaces consecutive message \
|
||||
groups inside it, and keys an unread divider apart from the group below it. While the \
|
||||
master switch below is off every client keeps the rendering it ships with, whatever the \
|
||||
rest of these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_blocked_message_groups"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (blocked_message_groups.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"blocked_groups_enabled",
|
||||
"true",
|
||||
"Serve blocked message groups assignments to clients",
|
||||
blocked_message_groups.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
rollout is inert and keeps its current rendering, so the rollout \
|
||||
and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"blocked_groups_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&blocked_message_groups.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"blocked_groups_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&blocked_message_groups.rollout_salt,
|
||||
"blocked-message-groups-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"blocked_groups_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save; blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"blocked_groups_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Blocked Message Groups Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn guild_activity_log_presentation_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
guild_activity_log_presentation: &GuildActivityLogPresentationConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if guild_activity_log_presentation.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = guild_activity_log_presentation.included_user_ids.join("\n");
|
||||
let excluded_user_ids = guild_activity_log_presentation.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Guild Activity Log Presentation",
|
||||
"Picks which activity log rendering targeted clients run in community settings. A \
|
||||
targeted client renders each activity log entry through the rewritten presenters. \
|
||||
While the master switch below is off every client keeps the previous activity log \
|
||||
rendering, whatever the rest of these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_guild_activity_log_presentation"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (guild_activity_log_presentation.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"guild_activity_log_presentation_enabled",
|
||||
"true",
|
||||
"Serve guild activity log presentation assignments to clients",
|
||||
guild_activity_log_presentation.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
rollout is inert and keeps the previous activity log rendering, so the \
|
||||
rollout and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"guild_activity_log_presentation_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&guild_activity_log_presentation.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"guild_activity_log_presentation_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&guild_activity_log_presentation.rollout_salt,
|
||||
"guild-activity-log-presentation-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"guild_activity_log_presentation_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"guild_activity_log_presentation_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Guild Activity Log Presentation Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn expression_info_card_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
expression_info_card: &ExpressionInfoCardConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if expression_info_card.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = expression_info_card.included_user_ids.join("\n");
|
||||
let excluded_user_ids = expression_info_card.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Expression Info Card",
|
||||
"Picks what a targeted client shows for an emoji or a sticker in a message. A targeted \
|
||||
client opens a click-triggered info card that names the expression, says where it comes \
|
||||
from, and offers a row for the source community the reader can open. While the master \
|
||||
switch below is off every client keeps the hover tooltip it ships with, whatever the \
|
||||
rest of these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_expression_info_card"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (expression_info_card.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"expression_card_enabled",
|
||||
"true",
|
||||
"Serve expression info card assignments to clients",
|
||||
expression_info_card.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
rollout is inert and keeps its current tooltip, so the rollout \
|
||||
and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"expression_card_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&expression_info_card.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"expression_card_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&expression_info_card.rollout_salt,
|
||||
"expression-info-card-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"expression_card_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save; blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"expression_card_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Expression Info Card Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn guild_header_collapse_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
guild_header_collapse: &GuildHeaderCollapseConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if guild_header_collapse.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = guild_header_collapse.included_user_ids.join("\n");
|
||||
let excluded_user_ids = guild_header_collapse.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Guild Header Collapse",
|
||||
"Picks how a targeted client draws the guild banner above the channel list. A targeted \
|
||||
client reduces that banner to the height of the header as the channel list scrolls down \
|
||||
and returns it to full height as the list scrolls back up. While the master switch below \
|
||||
is off every client keeps the fixed banner height it ships with, whatever the rest of \
|
||||
these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_guild_header_collapse"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (guild_header_collapse.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"guild_header_collapse_enabled",
|
||||
"true",
|
||||
"Serve guild header collapse assignments to clients",
|
||||
guild_header_collapse.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
rollout is inert and keeps its current banner height, so the rollout \
|
||||
and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"guild_header_collapse_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&guild_header_collapse.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"guild_header_collapse_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&guild_header_collapse.rollout_salt,
|
||||
"guild-header-collapse-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"guild_header_collapse_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"guild_header_collapse_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Guild Header Collapse Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn typing_indicator_rework_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
typing_indicator_rework: &TypingIndicatorReworkConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if typing_indicator_rework.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = typing_indicator_rework.included_user_ids.join("\n");
|
||||
let excluded_user_ids = typing_indicator_rework.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Typing Indicator Rework",
|
||||
"Picks how a targeted client sends and shows typing indicators. A targeted client sends a \
|
||||
typing signal 1.5 seconds after someone starts typing and then at most once every 8 \
|
||||
seconds, names up to three typists, and announces those names to screen readers even \
|
||||
where the visible row collapses them. While the master switch below is off every client \
|
||||
keeps the typing behaviour it ships with, whatever the rest of these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_typing_indicator_rework"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (typing_indicator_rework.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"typing_indicator_rework_enabled",
|
||||
"true",
|
||||
"Serve typing indicator rework assignments to clients",
|
||||
typing_indicator_rework.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
rollout is inert and keeps its current typing behaviour, so the rollout \
|
||||
and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"typing_indicator_rework_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&typing_indicator_rework.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"typing_indicator_rework_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&typing_indicator_rework.rollout_salt,
|
||||
"typing-indicator-rework-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"typing_indicator_rework_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"typing_indicator_rework_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Typing Indicator Rework Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -2568,44 +1842,4 @@ mod tests {
|
||||
assert!(markup.contains("1000 of 1000 stored"));
|
||||
assert!(markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guild_header_collapse_section_posts_its_own_action_and_fields() {
|
||||
let guild_header_collapse = GuildHeaderCollapseConfigResponse {
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec!["1500000000000000002".to_owned()],
|
||||
..GuildHeaderCollapseConfigResponse::default()
|
||||
};
|
||||
let markup =
|
||||
guild_header_collapse_section("/admin", "csrf", &guild_header_collapse).into_string();
|
||||
assert!(markup.contains("/instance-config?action=update_guild_header_collapse"));
|
||||
assert!(markup.contains("Guild Header Collapse"));
|
||||
assert!(markup.contains("guild-header-collapse-v1"));
|
||||
assert!(markup.contains("guild_header_collapse_enabled"));
|
||||
assert!(markup.contains("guild_header_collapse_rollout_basis_points"));
|
||||
assert!(markup.contains("guild_header_collapse_rollout_salt"));
|
||||
assert!(markup.contains("guild_header_collapse_included_user_ids"));
|
||||
assert!(markup.contains("guild_header_collapse_excluded_user_ids"));
|
||||
assert!(!markup.contains("expression_card_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_indicator_rework_section_posts_its_own_action_and_fields() {
|
||||
let typing_indicator_rework = TypingIndicatorReworkConfigResponse {
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec!["1500000000000000002".to_owned()],
|
||||
..TypingIndicatorReworkConfigResponse::default()
|
||||
};
|
||||
let markup = typing_indicator_rework_section("/admin", "csrf", &typing_indicator_rework)
|
||||
.into_string();
|
||||
assert!(markup.contains("/instance-config?action=update_typing_indicator_rework"));
|
||||
assert!(markup.contains("Typing Indicator Rework"));
|
||||
assert!(markup.contains("typing-indicator-rework-v1"));
|
||||
assert!(markup.contains("typing_indicator_rework_enabled"));
|
||||
assert!(markup.contains("typing_indicator_rework_rollout_basis_points"));
|
||||
assert!(markup.contains("typing_indicator_rework_rollout_salt"));
|
||||
assert!(markup.contains("typing_indicator_rework_included_user_ids"));
|
||||
assert!(markup.contains("typing_indicator_rework_excluded_user_ids"));
|
||||
assert!(!markup.contains("expression_card_"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ fn deserialize_audit_logs_response() {
|
||||
"target_type": "user",
|
||||
"target_id": "1130958221824557056",
|
||||
"action": "list_user_sessions",
|
||||
"access": "read",
|
||||
"audit_log_reason": null,
|
||||
"metadata": {"session_count": "3"},
|
||||
"created_at": "2026-05-26T13:21:47.138Z"
|
||||
@@ -243,6 +244,7 @@ fn deserialize_audit_logs_response() {
|
||||
assert_eq!(resp.logs.len(), 1);
|
||||
assert_eq!(resp.logs[0].log_id, "1508822460457747580");
|
||||
assert_eq!(resp.logs[0].action, "list_user_sessions");
|
||||
assert_eq!(resp.logs[0].access.as_deref(), Some("read"));
|
||||
assert_eq!(resp.logs[0].target_type, "user");
|
||||
assert!(resp.logs[0].audit_log_reason.is_none());
|
||||
assert_eq!(resp.logs[0].metadata.get("session_count").unwrap(), "3");
|
||||
@@ -402,69 +404,13 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"stereo_enabled": false,
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"guild_activity_log_presentation": {
|
||||
"enabled": true,
|
||||
"config_version": 2,
|
||||
"rollout_basis_points": 5000,
|
||||
"rollout_salt": "guild-activity-log-presentation-v1",
|
||||
"included_user_ids": ["1130650140672000000"],
|
||||
"excluded_user_ids": [],
|
||||
"future_presentation_knob": "verbose"
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"message_hover_tracking": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "message-hover-tracking-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"message_keyboard_focus": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "message-keyboard-focus-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"blocked_message_groups": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "blocked-message-groups-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"expression_info_card": {
|
||||
"enabled": true,
|
||||
"config_version": 3,
|
||||
"rollout_basis_points": 2500,
|
||||
"rollout_salt": "expression-info-card-v1",
|
||||
"included_user_ids": ["1130650140672000000"],
|
||||
"excluded_user_ids": ["1130958221824557056"],
|
||||
"suppression_strength": 80,
|
||||
"future_presentation_knob": "verbose",
|
||||
"future_knob": 7,
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"]
|
||||
},
|
||||
"guild_header_collapse": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "guild-header-collapse-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"typing_indicator_rework": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "typing-indicator-rework-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
"admin_registration_urls_enabled": false,
|
||||
@@ -588,28 +534,11 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
);
|
||||
|
||||
assert!(!resp.self_hosted);
|
||||
assert!(resp.expression_info_card.enabled);
|
||||
assert_eq!(resp.expression_info_card.config_version, 3);
|
||||
assert_eq!(resp.expression_info_card.rollout_basis_points, 2500);
|
||||
assert_eq!(
|
||||
*resp.expression_info_card.rollout_salt,
|
||||
"expression-info-card-v1"
|
||||
);
|
||||
assert_eq!(resp.expression_info_card.included_user_ids.len(), 1);
|
||||
assert_eq!(
|
||||
*resp.expression_info_card.excluded_user_ids[0],
|
||||
"1130958221824557056"
|
||||
);
|
||||
assert!(resp.guild_activity_log_presentation.enabled);
|
||||
assert_eq!(
|
||||
*resp.guild_activity_log_presentation.rollout_salt,
|
||||
"guild-activity-log-presentation-v1"
|
||||
);
|
||||
assert!(!resp.message_hover_tracking.enabled);
|
||||
assert!(!resp.message_keyboard_focus.enabled);
|
||||
assert!(!resp.blocked_message_groups.enabled);
|
||||
assert!(!resp.guild_header_collapse.enabled);
|
||||
assert!(!resp.typing_indicator_rework.enabled);
|
||||
assert!(resp.voice_noise_suppression.enabled);
|
||||
assert_eq!(resp.voice_noise_suppression.config_version, 4);
|
||||
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
|
||||
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
|
||||
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
|
||||
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
|
||||
assert!(resp.policy.single_community_guild_id.is_none());
|
||||
assert_eq!(resp.policy.services.gif_enabled, Some(true));
|
||||
|
||||
@@ -168,6 +168,39 @@ async fn detail_tab_routes_return_layout_or_fragments_by_route_shape() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_audit_log_tabs_request_write_entries_only() {
|
||||
let app = setup().await;
|
||||
for path in [
|
||||
"/users/1500000000000000001/tabs/audit_logs",
|
||||
"/guilds/1600000000000000001/tabs/audit_logs",
|
||||
] {
|
||||
let fragment = get(&app, path, &[]).await;
|
||||
assert!(fragment.contains("Temp ban"), "{path}\n{fragment}");
|
||||
assert!(!fragment.contains("Get user"), "{path}\n{fragment}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_log_page_forwards_the_access_filter() {
|
||||
let app = setup().await;
|
||||
let all = get(&app, "/audit-logs", &[]).await;
|
||||
assert!(all.contains("Temp ban"), "{all}");
|
||||
assert!(all.contains("Get user"), "{all}");
|
||||
assert!(
|
||||
all.contains(r#"<option value="" selected>All entries</option>"#),
|
||||
"{all}"
|
||||
);
|
||||
|
||||
let reads = get(&app, "/audit-logs?access=read", &[]).await;
|
||||
assert!(reads.contains("Get user"), "{reads}");
|
||||
assert!(!reads.contains("Temp ban"), "{reads}");
|
||||
assert!(
|
||||
reads.contains(r#"<option value="read" selected>Reads only</option>"#),
|
||||
"{reads}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_routes_keep_layout_and_fragment_contract() {
|
||||
let app = setup().await;
|
||||
@@ -432,8 +465,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_voice_noise_suppression",
|
||||
"/instance-config?action=update_guild_header_collapse",
|
||||
"/instance-config?action=update_typing_indicator_rework",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
@@ -846,6 +877,25 @@ async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
json_response(instance_config_without_pending_registrations())
|
||||
}
|
||||
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
|
||||
(Method::GET, "/admin/audit-logs") => {
|
||||
let access = uri.query().and_then(|query| {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.find(|(key, _)| key == "access")
|
||||
.map(|(_, value)| value.into_owned())
|
||||
});
|
||||
let logs = [
|
||||
audit_log_entry("1900000000000000101", "get_user", "read"),
|
||||
audit_log_entry("1900000000000000102", "temp_ban", "write"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
access
|
||||
.as_deref()
|
||||
.is_none_or(|access| entry.access.to_string() == access)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
json_response(json!({ "total": logs.len(), "logs": logs }))
|
||||
}
|
||||
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
|
||||
}
|
||||
}
|
||||
@@ -977,6 +1027,32 @@ fn guild_fixtures_match_generated_response_contracts() {
|
||||
assert_eq!(detail.member_count, 12);
|
||||
}
|
||||
|
||||
fn audit_log_entry(
|
||||
log_id: &str,
|
||||
action: &str,
|
||||
access: &str,
|
||||
) -> generated_types::AdminAuditLogResponseSchema {
|
||||
serde_json::from_value(json!({
|
||||
"log_id": log_id,
|
||||
"admin_user_id": "1500000000000000000",
|
||||
"admin_user": null,
|
||||
"target_type": "user",
|
||||
"target_id": "1500000000000000001",
|
||||
"target_user": null,
|
||||
"target_guild": null,
|
||||
"target_channel": null,
|
||||
"related_users": {},
|
||||
"related_guilds": {},
|
||||
"related_channels": {},
|
||||
"action": action,
|
||||
"access": access,
|
||||
"audit_log_reason": null,
|
||||
"metadata": {},
|
||||
"created_at": "2026-09-16T12:00:00.000Z"
|
||||
}))
|
||||
.expect("audit log fixture must match the generated response contract")
|
||||
}
|
||||
|
||||
fn searched_application() -> Value {
|
||||
json!({
|
||||
"id": "1700000000000000001",
|
||||
@@ -1099,22 +1175,6 @@ fn instance_config() -> Value {
|
||||
"max_concurrent_guild_starts": 16,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"guild_header_collapse": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "guild-header-collapse-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"typing_indicator_rework": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "typing-indicator-rework-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": []
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {AuditLogSearchFilters, SearchableAuditLog} from '@fluxer/schema/src
|
||||
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters, esTermFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters, esTermFilter, esTermsFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
|
||||
function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
@@ -15,6 +15,10 @@ function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<Elasticsear
|
||||
if (filters.targetType) clauses.push(esTermFilter('targetType', filters.targetType));
|
||||
if (filters.targetId) clauses.push(esTermFilter('targetId', filters.targetId));
|
||||
if (filters.action) clauses.push(esTermFilter('action', filters.action));
|
||||
if (filters.actions && filters.actions.length > 0) clauses.push(esTermsFilter('action.keyword', filters.actions));
|
||||
if (filters.excludeActions && filters.excludeActions.length > 0) {
|
||||
clauses.push({bool: {must_not: [esTermsFilter('action.keyword', filters.excludeActions)]}});
|
||||
}
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface IKVSubscription {
|
||||
}
|
||||
|
||||
export interface KVPurgeBatchResult {
|
||||
urls: Array<string>;
|
||||
entries: Array<string>;
|
||||
tokensConsumed: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ local refillIntervalMs = tonumber(ARGV[5])
|
||||
|
||||
local queueSize = redis.call('SCARD', queueKey)
|
||||
if queueSize == 0 then
|
||||
return '{"urls":[],"tokens":0}'
|
||||
return '{"entries":[],"tokens":0}'
|
||||
end
|
||||
|
||||
local tokens = maxTokens
|
||||
@@ -237,14 +237,14 @@ end
|
||||
local toPop = math.min(maxItems, math.floor(tokens), queueSize)
|
||||
if toPop <= 0 then
|
||||
redis.call('SET', bucketKey, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
|
||||
return '{"urls":[],"tokens":0}'
|
||||
return '{"entries":[],"tokens":0}'
|
||||
end
|
||||
|
||||
local urls = redis.call('SPOP', queueKey, toPop)
|
||||
tokens = tokens - #urls
|
||||
local entries = redis.call('SPOP', queueKey, toPop)
|
||||
tokens = tokens - #entries
|
||||
|
||||
redis.call('SET', bucketKey, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
|
||||
return cjson.encode({urls = urls, tokens = #urls})
|
||||
return cjson.encode({entries = entries, tokens = #entries})
|
||||
`;
|
||||
const CLAIM_BULK_DELETION_SCRIPT = `
|
||||
local score = redis.call('ZSCORE', KEYS[1], ARGV[1])
|
||||
@@ -902,17 +902,17 @@ function parseRateLimitResult(value: unknown): KVRateLimitResult {
|
||||
function parsePurgeBatchResult(value: unknown, maxItems: number): KVPurgeBatchResult {
|
||||
const command = 'dequeuePurgeBatch';
|
||||
if (!isJsonObject(value)) throw createInvalidResponseError(command, 'a purge batch object');
|
||||
const {urls, tokens} = value;
|
||||
const {entries, tokens} = value;
|
||||
if (
|
||||
!Array.isArray(urls) ||
|
||||
!urls.every((url): url is string => typeof url === 'string') ||
|
||||
!Array.isArray(entries) ||
|
||||
!entries.every((entry): entry is string => typeof entry === 'string') ||
|
||||
!isNonNegativeSafeInteger(tokens) ||
|
||||
tokens !== urls.length ||
|
||||
urls.length > maxItems
|
||||
tokens !== entries.length ||
|
||||
entries.length > maxItems
|
||||
) {
|
||||
throw createInvalidResponseError(command, 'a bounded string array and matching token count');
|
||||
}
|
||||
return {urls, tokensConsumed: tokens};
|
||||
return {entries, tokensConsumed: tokens};
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('KVClient script execution', () => {
|
||||
},
|
||||
{
|
||||
name: 'dequeuePurgeBatch',
|
||||
reply: JSON.stringify({urls: ['https://fluxer.test/a.png'], tokens: 1}),
|
||||
reply: JSON.stringify({entries: ['/attachments/1/2/a'], tokens: 1}),
|
||||
keyCount: 2,
|
||||
run: async (client) => client.dequeuePurgeBatch('queue:key', 'bucket:key', 10, 10, 1, 1000),
|
||||
},
|
||||
|
||||
@@ -479,6 +479,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
wordmarkUrl: master.instance.branding.wordmark_url,
|
||||
faviconUrl: master.instance.branding.favicon_url,
|
||||
themeColor: master.instance.branding.theme_color,
|
||||
statusPageUrl: master.instance.branding.status_page_url,
|
||||
statusPageIncidentHistoryUrl: master.instance.branding.status_page_incident_history_url,
|
||||
},
|
||||
setup: {
|
||||
configured: master.instance.setup.configured,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
import type {AdminAuditAccess} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
export const AdminAuditReadActions = {
|
||||
CHECK_BLOCKLIST_ENTRY: 'check_blocklist_entry',
|
||||
GET_ADMIN_API_KEY: 'get_admin_api_key',
|
||||
GET_APPLICATION: 'get_application',
|
||||
GET_ARCHIVE: 'get_archive',
|
||||
GET_ARCHIVE_DOWNLOAD_URL: 'get_archive_download_url',
|
||||
GET_AUDIT_LOG: 'get_audit_log',
|
||||
GET_GATEWAY_STATS: 'get_gateway_stats',
|
||||
GET_GUILD: 'get_guild',
|
||||
GET_INSTANCE_CONFIG: 'get_instance_config',
|
||||
GET_LIMIT_CONFIG: 'get_limit_config',
|
||||
GET_MESSAGE: 'get_message',
|
||||
GET_MESSAGE_SHRED_STATUS: 'get_message_shred_status',
|
||||
GET_REPORT: 'get_report',
|
||||
GET_USER: 'get_user',
|
||||
GET_VOICE_REGION: 'get_voice_region',
|
||||
GET_VOICE_SERVER: 'get_voice_server',
|
||||
GET_VOICE_STATE_COUNTS: 'get_voice_state_counts',
|
||||
LIST_ADMIN_ACLS: 'list_admin_acls',
|
||||
LIST_ADMIN_API_KEYS: 'list_admin_api_keys',
|
||||
LIST_ARCHIVES: 'list_archives',
|
||||
LIST_AUDIT_LOGS: 'list_audit_logs',
|
||||
LIST_BLOCKLIST_ENTRIES: 'list_blocklist_entries',
|
||||
LIST_BLOCKLISTS: 'list_blocklists',
|
||||
LIST_CHANNEL_MESSAGES: 'list_channel_messages',
|
||||
LIST_DISCOVERY_APPLICATIONS: 'list_discovery_applications',
|
||||
LIST_DISCOVERY_CATEGORIES: 'list_discovery_categories',
|
||||
LIST_DISCOVERY_CATEGORY_LISTINGS: 'list_discovery_category_listings',
|
||||
LIST_DISCOVERY_LISTINGS: 'list_discovery_listings',
|
||||
LIST_GUILD_APPLICATIONS: 'list_guild_applications',
|
||||
LIST_GUILD_AUDIT_LOGS: 'list_guild_audit_logs',
|
||||
LIST_GUILD_EMOJIS: 'list_guild_emojis',
|
||||
LIST_GUILD_MEMBERS: 'list_guild_members',
|
||||
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
|
||||
LIST_GUILD_STICKERS: 'list_guild_stickers',
|
||||
LIST_USER_APPLICATIONS: 'list_user_applications',
|
||||
LIST_USER_CHANGE_LOG: 'list_user_change_log',
|
||||
LIST_USER_DM_CHANNELS: 'list_user_dm_channels',
|
||||
LIST_USER_GUILDS: 'list_user_guilds',
|
||||
LIST_USER_RELATIONSHIPS: 'list_user_relationships',
|
||||
LIST_USER_SESSIONS: 'list_user_sessions',
|
||||
LIST_VOICE_REGIONS: 'list_voice_regions',
|
||||
LIST_VOICE_SERVERS: 'list_voice_servers',
|
||||
LIST_WEBAUTHN_CREDENTIALS: 'list_webauthn_credentials',
|
||||
SEARCH_AUDIT_LOGS: 'search_audit_logs',
|
||||
SEARCH_GUILDS: 'search_guilds',
|
||||
SEARCH_MESSAGES: 'search_messages',
|
||||
SEARCH_REPORTS: 'search_reports',
|
||||
SEARCH_USERS: 'search_users',
|
||||
} as const;
|
||||
|
||||
export type AdminAuditReadAction = ValueOf<typeof AdminAuditReadActions>;
|
||||
|
||||
export const ADMIN_AUDIT_READ_ACTIONS: ReadonlyArray<AdminAuditReadAction> = Object.values(AdminAuditReadActions);
|
||||
|
||||
const READ_ACTION_SET: ReadonlySet<string> = new Set(ADMIN_AUDIT_READ_ACTIONS);
|
||||
|
||||
export function getAdminAuditAccess(action: string): AdminAuditAccess {
|
||||
return READ_ACTION_SET.has(action) ? 'read' : 'write';
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditReadAction} from '@app/api/admin/AdminAuditActions';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import type {Context} from 'hono';
|
||||
|
||||
type AdminAuditMetadataValue = string | number | bigint | boolean | null | undefined;
|
||||
|
||||
type AdminAuditMetadataInput = Readonly<Record<string, AdminAuditMetadataValue>>;
|
||||
|
||||
interface AdminAuditEntryInput<TAction extends string> {
|
||||
targetType: string;
|
||||
targetId: bigint;
|
||||
action: TAction;
|
||||
metadata?: AdminAuditMetadataInput;
|
||||
}
|
||||
|
||||
const SNOWFLAKE_PATTERN = /^(0|[1-9][0-9]{0,19})$/;
|
||||
|
||||
export function snowflakeOrUndefined(value: string | undefined): string | undefined {
|
||||
return value !== undefined && SNOWFLAKE_PATTERN.test(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function toAdminAuditMetadata(input: AdminAuditMetadataInput = {}): Map<string, string> {
|
||||
const metadata = new Map<string, string>();
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
metadata.set(key, String(value));
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function recordAdminAuditEntry(ctx: Context<HonoEnv>, entry: AdminAuditEntryInput<string>): Promise<void> {
|
||||
await ctx.get('adminService').auditService.createAuditLog({
|
||||
adminUserId: ctx.get('adminUserId'),
|
||||
targetType: entry.targetType,
|
||||
targetId: entry.targetId,
|
||||
action: entry.action,
|
||||
auditLogReason: ctx.get('auditLogReason'),
|
||||
metadata: toAdminAuditMetadata(entry.metadata),
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordAdminRead(
|
||||
ctx: Context<HonoEnv>,
|
||||
entry: AdminAuditEntryInput<AdminAuditReadAction>,
|
||||
): Promise<void> {
|
||||
await recordAdminAuditEntry(ctx, entry);
|
||||
}
|
||||
|
||||
export async function recordAdminWrite(ctx: Context<HonoEnv>, entry: AdminAuditEntryInput<string>): Promise<void> {
|
||||
await recordAdminAuditEntry(ctx, entry);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import type {AdminApiKeyView} from '@app/api/admin/services/AdminApiKeyService';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -62,6 +64,15 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
expires_at: result.apiKey.expiresAt?.toISOString() ?? null,
|
||||
acls: Array.from(result.apiKey.acls),
|
||||
};
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'admin_api_key',
|
||||
targetId: BigInt(result.apiKey.keyId),
|
||||
action: 'create_admin_api_key',
|
||||
metadata: {
|
||||
acls: response.acls.join(','),
|
||||
expires_in_days: request.expires_in_days,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
@@ -84,6 +95,12 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
const user = ctx.get('user');
|
||||
const keys = await adminApiKeyService.listKeys(user.id);
|
||||
const response: Array<ListAdminApiKeyResponseType> = keys.map(toApiKeyResponse);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'admin_api_key',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_ADMIN_API_KEYS,
|
||||
metadata: {result_count: response.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
@@ -107,6 +124,11 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
const user = ctx.get('user');
|
||||
const keyId = ctx.req.valid('param').key_id;
|
||||
const key = await adminApiKeyService.getKey(keyId, user.id);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'admin_api_key',
|
||||
targetId: keyId,
|
||||
action: AdminAuditReadActions.GET_ADMIN_API_KEY,
|
||||
});
|
||||
return ctx.json(toApiKeyResponse(key));
|
||||
},
|
||||
);
|
||||
@@ -131,8 +153,19 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
const user = ctx.get('user');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const keyId = ctx.req.valid('param').key_id;
|
||||
const key = await adminApiKeyService.updateKey(keyId, user.id, ctx.req.valid('json'), adminUserAcls);
|
||||
return ctx.json(toApiKeyResponse(key));
|
||||
const request = ctx.req.valid('json');
|
||||
const key = await adminApiKeyService.updateKey(keyId, user.id, request, adminUserAcls);
|
||||
const response = toApiKeyResponse(key);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'admin_api_key',
|
||||
targetId: keyId,
|
||||
action: 'update_admin_api_key',
|
||||
metadata: {
|
||||
fields: (['name', 'acls'] as const).filter((field) => request[field] !== undefined).join(','),
|
||||
acls: request.acls !== undefined ? response.acls.join(',') : undefined,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
@@ -155,6 +188,11 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
const user = ctx.get('user');
|
||||
const keyId = ctx.req.valid('param').key_id;
|
||||
await adminApiKeyService.revokeKey(keyId, user.id);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'admin_api_key',
|
||||
targetId: keyId,
|
||||
action: 'revoke_admin_api_key',
|
||||
});
|
||||
return ctx.json({success: true}, 200);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createApplicationID, createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -50,11 +52,25 @@ export function ApplicationAdminController(app: HonoApp) {
|
||||
}
|
||||
if (guildId != null) {
|
||||
requireRequestAdminACL(ctx.get('adminUserAcls'), AdminACLs.APPLICATION_LOOKUP);
|
||||
return ctx.json(await adminService.applicationService.listGuildApplications(createGuildID(guildId)));
|
||||
const response = await adminService.applicationService.listGuildApplications(createGuildID(guildId));
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
action: AdminAuditReadActions.LIST_GUILD_APPLICATIONS,
|
||||
metadata: {application_count: response.applications.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
}
|
||||
if (ownerId != null) {
|
||||
requireRequestAdminACL(ctx.get('adminUserAcls'), AdminACLs.APPLICATION_LIST_BY_OWNER);
|
||||
return ctx.json(await adminService.applicationService.listUserApplications(createUserID(ownerId)));
|
||||
const response = await adminService.applicationService.listUserApplications(createUserID(ownerId));
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: ownerId,
|
||||
action: AdminAuditReadActions.LIST_USER_APPLICATIONS,
|
||||
metadata: {application_count: response.applications.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
}
|
||||
throw InputValidationError.create('owner_id', 'One of owner_id and guild_id is required');
|
||||
},
|
||||
@@ -76,7 +92,14 @@ export function ApplicationAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const userId = createUserID(ctx.req.valid('param').user_id);
|
||||
return ctx.json(await adminService.applicationService.listUserApplications(userId));
|
||||
const response = await adminService.applicationService.listUserApplications(userId);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: AdminAuditReadActions.LIST_USER_APPLICATIONS,
|
||||
metadata: {application_count: response.applications.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -97,7 +120,18 @@ export function ApplicationAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const applicationId = createApplicationID(ctx.req.valid('param').application_id);
|
||||
return ctx.json(await adminService.applicationService.lookupApplication(applicationId));
|
||||
const response = await adminService.applicationService.lookupApplication(applicationId);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'application',
|
||||
targetId: applicationId,
|
||||
action: AdminAuditReadActions.GET_APPLICATION,
|
||||
metadata: {
|
||||
found: response.application !== null,
|
||||
owner_user_id: response.application?.owner_user_id,
|
||||
bot_user_id: response.application?.bot_user_id,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -66,11 +68,15 @@ export function ArchiveAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const result = await adminArchiveService.triggerUserArchive(
|
||||
createUserID(ctx.req.valid('param').user_id),
|
||||
adminUserId,
|
||||
ctx.req.valid('json').include_attachments,
|
||||
);
|
||||
const userId = createUserID(ctx.req.valid('param').user_id);
|
||||
const includeAttachments = ctx.req.valid('json').include_attachments;
|
||||
const result = await adminArchiveService.triggerUserArchive(userId, adminUserId, includeAttachments);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: 'trigger_user_archive',
|
||||
metadata: {archive_id: result.archive_id, include_attachments: includeAttachments},
|
||||
});
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
@@ -93,11 +99,15 @@ export function ArchiveAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const result = await adminArchiveService.triggerGuildArchive(
|
||||
createGuildID(ctx.req.valid('param').guild_id),
|
||||
adminUserId,
|
||||
ctx.req.valid('json').include_attachments,
|
||||
);
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
const includeAttachments = ctx.req.valid('json').include_attachments;
|
||||
const result = await adminArchiveService.triggerGuildArchive(guildId, adminUserId, includeAttachments);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
action: 'trigger_guild_archive',
|
||||
metadata: {archive_id: result.archive_id, include_attachments: includeAttachments},
|
||||
});
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
@@ -120,13 +130,28 @@ export function ArchiveAdminController(app: HonoApp) {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const query = ctx.req.valid('query');
|
||||
const subjectType = resolveListSubjectType(adminAcls, query.subject_type);
|
||||
const result = await adminArchiveService.listArchives({
|
||||
subjectType: resolveListSubjectType(adminAcls, query.subject_type),
|
||||
subjectType,
|
||||
subjectId: query.subject_id ?? undefined,
|
||||
requestedBy: query.requested_by ?? undefined,
|
||||
limit: query.limit,
|
||||
includeExpired: query.include_expired,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'archive',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_ARCHIVES,
|
||||
metadata: {
|
||||
subject_type: subjectType,
|
||||
subject_user_id: subjectType === 'user' ? query.subject_id : undefined,
|
||||
subject_guild_id: subjectType === 'guild' ? query.subject_id : undefined,
|
||||
requested_by_user_id: query.requested_by,
|
||||
limit: query.limit,
|
||||
include_expired: query.include_expired,
|
||||
result_count: result.length,
|
||||
},
|
||||
});
|
||||
return ctx.json({archives: result}, 200);
|
||||
},
|
||||
);
|
||||
@@ -151,6 +176,12 @@ export function ArchiveAdminController(app: HonoApp) {
|
||||
const params = ctx.req.valid('param');
|
||||
requireArchiveSubjectAccess(adminAcls, params.subject_type);
|
||||
const archive = await adminArchiveService.getArchive(params.subject_type, params.subject_id, params.archive_id);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: params.subject_type,
|
||||
targetId: params.subject_id,
|
||||
action: AdminAuditReadActions.GET_ARCHIVE,
|
||||
metadata: {archive_id: params.archive_id, found: archive !== null},
|
||||
});
|
||||
return ctx.json({archive}, 200);
|
||||
},
|
||||
);
|
||||
@@ -179,6 +210,12 @@ export function ArchiveAdminController(app: HonoApp) {
|
||||
params.subject_id,
|
||||
params.archive_id,
|
||||
);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: params.subject_type,
|
||||
targetId: params.subject_id,
|
||||
action: AdminAuditReadActions.GET_ARCHIVE_DOWNLOAD_URL,
|
||||
metadata: {archive_id: params.archive_id},
|
||||
});
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, snowflakeOrUndefined} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -16,6 +18,8 @@ import {
|
||||
ListAdminAuditLogsQuery,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
const AUDIT_TARGET_TYPE_PATTERN = /^[a-z][a-z_]{0,63}$/;
|
||||
|
||||
export function AuditLogAdminController(app: HonoApp) {
|
||||
app.get(
|
||||
'/admin/audit-logs',
|
||||
@@ -34,24 +38,50 @@ export function AuditLogAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {q, admin_user_id, target_type, target_id, sort_by, sort_order, limit, offset} = ctx.req.valid('query');
|
||||
if (q === undefined) {
|
||||
return ctx.json(
|
||||
await adminService.auditService.listAuditLogs({admin_user_id, target_type, target_id, limit, offset}),
|
||||
);
|
||||
}
|
||||
return ctx.json(
|
||||
await adminService.auditService.searchAuditLogs({
|
||||
query: q,
|
||||
admin_user_id,
|
||||
target_type,
|
||||
target_id,
|
||||
sort_by,
|
||||
sort_order,
|
||||
const {q, admin_user_id, target_type, target_id, access, sort_by, sort_order, limit, offset} =
|
||||
ctx.req.valid('query');
|
||||
const response =
|
||||
q === undefined
|
||||
? await adminService.auditService.listAuditLogs({
|
||||
admin_user_id,
|
||||
target_type,
|
||||
target_id,
|
||||
access,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
: await adminService.auditService.searchAuditLogs({
|
||||
query: q,
|
||||
admin_user_id,
|
||||
target_type,
|
||||
target_id,
|
||||
access,
|
||||
sort_by,
|
||||
sort_order,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'audit_log',
|
||||
targetId: 0n,
|
||||
action: q === undefined ? AdminAuditReadActions.LIST_AUDIT_LOGS : AdminAuditReadActions.SEARCH_AUDIT_LOGS,
|
||||
metadata: {
|
||||
filter_admin_user_id: admin_user_id,
|
||||
filter_target_type:
|
||||
target_type !== undefined && AUDIT_TARGET_TYPE_PATTERN.test(target_type) ? target_type : undefined,
|
||||
has_target_type_filter:
|
||||
target_type !== undefined && !AUDIT_TARGET_TYPE_PATTERN.test(target_type) ? true : undefined,
|
||||
filter_target_id: snowflakeOrUndefined(target_id),
|
||||
access,
|
||||
sort_by: q === undefined ? undefined : sort_by,
|
||||
sort_order: q === undefined ? undefined : sort_order,
|
||||
limit,
|
||||
offset,
|
||||
}),
|
||||
);
|
||||
result_count: response.logs.length,
|
||||
total: response.total,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -76,6 +106,11 @@ export function AuditLogAdminController(app: HonoApp) {
|
||||
if (!log) {
|
||||
throw new NotFoundError({code: APIErrorCodes.NOT_FOUND});
|
||||
}
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'audit_log',
|
||||
targetId: log_id,
|
||||
action: AdminAuditReadActions.GET_AUDIT_LOG,
|
||||
});
|
||||
return ctx.json(log);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import type {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -180,6 +183,18 @@ const BLOCKLIST_TYPE_ACLS: Record<AdminBlocklistListType, {add: string; check: s
|
||||
},
|
||||
};
|
||||
|
||||
const BLOCKLIST_AUDIT_TARGET_TYPES: Record<AdminBlocklistListType, string> = {
|
||||
ip: 'ip',
|
||||
email: 'email',
|
||||
'email-domain-suspicious': 'email_domain',
|
||||
phrase: 'phrase',
|
||||
url: 'url',
|
||||
'url-domain': 'url_domain',
|
||||
'file-sha': 'file_sha',
|
||||
'avatar-hash': 'avatar_hash',
|
||||
'profile-substring': 'profile_substring',
|
||||
};
|
||||
|
||||
type BlocklistVerb = 'add' | 'check' | 'remove';
|
||||
|
||||
const BLOCKLIST_ACLS_BY_VERB: Record<BlocklistVerb, Array<string>> = {
|
||||
@@ -229,6 +244,37 @@ async function parseBlocklistBody<T>(schema: ZodType<T>, value: unknown): Promis
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function checkBlocklistEntry(
|
||||
bans: AdminBanManagementService,
|
||||
listType: AdminBlocklistListType,
|
||||
entryValue: string,
|
||||
scope: ProfileSubstringScope | undefined,
|
||||
): Promise<{banned: boolean}> {
|
||||
switch (listType) {
|
||||
case 'ip':
|
||||
return bans.checkIpBan({ip: entryValue});
|
||||
case 'email':
|
||||
return bans.checkEmailBan({email: entryValue});
|
||||
case 'email-domain-suspicious':
|
||||
return bans.checkSuspiciousEmailDomain({domain: entryValue});
|
||||
case 'phrase':
|
||||
return bans.checkPhraseBan({phrase: entryValue});
|
||||
case 'url':
|
||||
return bans.checkUrlBan({url: entryValue});
|
||||
case 'url-domain':
|
||||
return bans.checkUrlDomainBan({domain: entryValue});
|
||||
case 'file-sha':
|
||||
return bans.checkFileShaBan({sha256_hex: entryValue});
|
||||
case 'avatar-hash':
|
||||
return bans.checkAvatarHashBan({hashes: [entryValue]});
|
||||
case 'profile-substring':
|
||||
return bans.checkProfileSubstringBan({
|
||||
scope: requireProfileSubstringScope(scope),
|
||||
substrings: [entryValue],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function BanAdminController(app: HonoApp) {
|
||||
app.get(
|
||||
'/admin/blocklists',
|
||||
@@ -245,6 +291,12 @@ export function BanAdminController(app: HonoApp) {
|
||||
'List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'blocklist',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_BLOCKLISTS,
|
||||
metadata: {result_count: BLOCKLIST_CATALOG.length},
|
||||
});
|
||||
return ctx.json({items: BLOCKLIST_CATALOG});
|
||||
},
|
||||
);
|
||||
@@ -270,14 +322,26 @@ export function BanAdminController(app: HonoApp) {
|
||||
requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'check');
|
||||
const {limit, after, scope} = ctx.req.valid('query');
|
||||
assertBlocklistScopeAllowed(listType, scope);
|
||||
return ctx.json(
|
||||
await adminService.banManagementService.listBlocklistEntries({
|
||||
listType,
|
||||
const page = await adminService.banManagementService.listBlocklistEntries({
|
||||
listType,
|
||||
limit,
|
||||
after: after ?? null,
|
||||
scope: listType === 'profile-substring' ? requireProfileSubstringScope(scope) : null,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: BLOCKLIST_AUDIT_TARGET_TYPES[listType],
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_BLOCKLIST_ENTRIES,
|
||||
metadata: {
|
||||
list_type: listType,
|
||||
scope,
|
||||
limit,
|
||||
after: after ?? null,
|
||||
scope: listType === 'profile-substring' ? requireProfileSubstringScope(scope) : null,
|
||||
}),
|
||||
);
|
||||
has_after: after === undefined ? undefined : true,
|
||||
result_count: page.items.length,
|
||||
has_more: page.has_more,
|
||||
},
|
||||
});
|
||||
return ctx.json(page);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -379,6 +443,15 @@ export function BanAdminController(app: HonoApp) {
|
||||
},
|
||||
{requestedByUserId: adminUserId, requireLedger: true, ...(auditLogReason && {auditLogReason})},
|
||||
);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'bulk_job',
|
||||
targetId: jobId,
|
||||
action: 'queue_bulk_job',
|
||||
metadata: {
|
||||
task: 'ban_file_shas',
|
||||
entity_count: body.sha256_list.length,
|
||||
},
|
||||
});
|
||||
return ctx.json({job_id: jobId.toString()});
|
||||
},
|
||||
);
|
||||
@@ -449,32 +522,18 @@ export function BanAdminController(app: HonoApp) {
|
||||
requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'check');
|
||||
const {scope} = ctx.req.valid('query');
|
||||
assertBlocklistScopeAllowed(listType, scope);
|
||||
const bans = adminService.banManagementService;
|
||||
switch (listType) {
|
||||
case 'ip':
|
||||
return ctx.json(await bans.checkIpBan({ip: entryValue}));
|
||||
case 'email':
|
||||
return ctx.json(await bans.checkEmailBan({email: entryValue}));
|
||||
case 'email-domain-suspicious':
|
||||
return ctx.json(await bans.checkSuspiciousEmailDomain({domain: entryValue}));
|
||||
case 'phrase':
|
||||
return ctx.json(await bans.checkPhraseBan({phrase: entryValue}));
|
||||
case 'url':
|
||||
return ctx.json(await bans.checkUrlBan({url: entryValue}));
|
||||
case 'url-domain':
|
||||
return ctx.json(await bans.checkUrlDomainBan({domain: entryValue}));
|
||||
case 'file-sha':
|
||||
return ctx.json(await bans.checkFileShaBan({sha256_hex: entryValue}));
|
||||
case 'avatar-hash':
|
||||
return ctx.json(await bans.checkAvatarHashBan({hashes: [entryValue]}));
|
||||
case 'profile-substring':
|
||||
return ctx.json(
|
||||
await bans.checkProfileSubstringBan({
|
||||
scope: requireProfileSubstringScope(scope),
|
||||
substrings: [entryValue],
|
||||
}),
|
||||
);
|
||||
}
|
||||
const result = await checkBlocklistEntry(adminService.banManagementService, listType, entryValue, scope);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: BLOCKLIST_AUDIT_TARGET_TYPES[listType],
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.CHECK_BLOCKLIST_ENTRY,
|
||||
metadata: {
|
||||
list_type: listType,
|
||||
scope,
|
||||
banned: result.banned,
|
||||
},
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -136,6 +137,16 @@ export function BulkAdminController(app: HonoApp) {
|
||||
throw new MissingACLError(requiredAcl);
|
||||
}
|
||||
const jobId = await queueBulkJob(body, adminUserId, auditLogReason);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'bulk_job',
|
||||
targetId: jobId,
|
||||
action: 'queue_bulk_job',
|
||||
metadata: {
|
||||
task: body.task,
|
||||
entity_count: 'guild_ids' in body ? body.guild_ids.length : body.user_ids.length,
|
||||
guild_id: body.task === AdminBulkTaskType.ADD_GUILD_MEMBERS ? body.guild_id : undefined,
|
||||
},
|
||||
});
|
||||
return ctx.json({job_id: jobId.toString()});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createGuildID} from '@app/api/BrandedTypes';
|
||||
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
|
||||
import {mapGuildFeatures} from '@app/api/guild/GuildFeatureUtils';
|
||||
@@ -30,6 +32,8 @@ import {
|
||||
DiscoveryCategoryListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
|
||||
const DISCOVERY_LISTING_FIELDS = ['description', 'category_type', 'primary_language', 'custom_tags'] as const;
|
||||
|
||||
function mapRowToApplicationResponse(row: GuildDiscoveryRow) {
|
||||
return {
|
||||
guild_id: row.guild_id.toString(),
|
||||
@@ -149,6 +153,12 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
const userRepository = ctx.get('userRepository');
|
||||
const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.PENDING});
|
||||
const enrichment = await enrichGuilds(rows, guildService, userRepository);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_DISCOVERY_APPLICATIONS,
|
||||
metadata: {result_count: rows.length},
|
||||
});
|
||||
return ctx.json(rows.map((row) => mapPendingResponse(row, enrichment.get(row.guild_id.toString()))));
|
||||
},
|
||||
);
|
||||
@@ -173,10 +183,16 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
const data = ctx.req.valid('json');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const row =
|
||||
data.status === DiscoveryApplicationStatus.APPROVED
|
||||
? await discoveryService.approve({guildId, adminUserId, reason: data.reason})
|
||||
: await discoveryService.reject({guildId, adminUserId, reason: data.reason});
|
||||
const approved = data.status === DiscoveryApplicationStatus.APPROVED;
|
||||
const row = approved
|
||||
? await discoveryService.approve({guildId, adminUserId, reason: data.reason})
|
||||
: await discoveryService.reject({guildId, adminUserId, reason: data.reason});
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
action: approved ? 'approve_discovery_application' : 'reject_discovery_application',
|
||||
metadata: {status: data.status},
|
||||
});
|
||||
return ctx.json(mapRowToApplicationResponse(row));
|
||||
},
|
||||
);
|
||||
@@ -195,12 +211,17 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
Object.entries(DiscoveryCategoryLabels).map(([id, name]) => ({
|
||||
id: Number(id),
|
||||
name,
|
||||
})),
|
||||
);
|
||||
const categories = Object.entries(DiscoveryCategoryLabels).map(([id, name]) => ({
|
||||
id: Number(id),
|
||||
name,
|
||||
}));
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'discovery_category',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_DISCOVERY_CATEGORIES,
|
||||
metadata: {result_count: categories.length},
|
||||
});
|
||||
return ctx.json(categories);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -233,11 +254,20 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
(enrichment.get(right.guild_id.toString())?.member_count ?? 0) -
|
||||
(enrichment.get(left.guild_id.toString())?.member_count ?? 0),
|
||||
);
|
||||
return ctx.json(
|
||||
sorted
|
||||
.slice(offset, offset + limit)
|
||||
.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))),
|
||||
);
|
||||
const page = sorted.slice(offset, offset + limit);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'discovery_category',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_DISCOVERY_CATEGORY_LISTINGS,
|
||||
metadata: {
|
||||
category_id,
|
||||
limit,
|
||||
offset,
|
||||
result_count: page.length,
|
||||
total: inCategory.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(page.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -260,6 +290,12 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
const userRepository = ctx.get('userRepository');
|
||||
const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.APPROVED});
|
||||
const enrichment = await enrichGuilds(rows, guildService, userRepository);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_DISCOVERY_LISTINGS,
|
||||
metadata: {result_count: rows.length},
|
||||
});
|
||||
return ctx.json(rows.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))));
|
||||
},
|
||||
);
|
||||
@@ -341,6 +377,18 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const row = await discoveryService.editApplication({guildId, userId: adminUserId, data});
|
||||
const fields = DISCOVERY_LISTING_FIELDS.filter((field) => data[field] !== undefined);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
action: 'update_discovery_listing',
|
||||
metadata: {
|
||||
fields: fields.length > 0 ? fields.join(',') : undefined,
|
||||
category_type: data.category_type,
|
||||
primary_language: data.primary_language,
|
||||
status: row.status,
|
||||
},
|
||||
});
|
||||
return ctx.json(mapRowToApplicationResponse(row));
|
||||
},
|
||||
);
|
||||
@@ -366,6 +414,11 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const row = await discoveryService.remove({guildId, adminUserId, reason: data.reason});
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
action: 'remove_discovery_listing',
|
||||
});
|
||||
return ctx.json(mapRowToApplicationResponse(row));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createGuildID} from '@app/api/BrandedTypes';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -34,7 +36,14 @@ export function GatewayAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.guildServiceAggregate.managementService.getNodeStats());
|
||||
const stats = await adminService.guildServiceAggregate.managementService.getNodeStats();
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'gateway',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.GET_GATEWAY_STATS,
|
||||
metadata: {node_count: stats.node_count},
|
||||
});
|
||||
return ctx.json(stats);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -54,7 +63,14 @@ export function GatewayAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {limit} = ctx.req.valid('query');
|
||||
return ctx.json(await adminService.guildServiceAggregate.managementService.getGuildMemoryStats(limit));
|
||||
const stats = await adminService.guildServiceAggregate.managementService.getGuildMemoryStats(limit);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_GUILD_MEMORY_STATS,
|
||||
metadata: {limit, result_count: stats.guilds.length},
|
||||
});
|
||||
return ctx.json(stats);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -73,7 +89,18 @@ export function GatewayAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.guildServiceAggregate.managementService.getVoiceStateCounts());
|
||||
const counts = await adminService.guildServiceAggregate.managementService.getVoiceStateCounts();
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'gateway',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.GET_VOICE_STATE_COUNTS,
|
||||
metadata: {
|
||||
total_voice_states: counts.total_voice_states,
|
||||
region_count: counts.regions.length,
|
||||
server_count: counts.servers.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(counts);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createGuildID} from '@app/api/BrandedTypes';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -107,13 +109,24 @@ export function GuildAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const query = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.searchService.searchGuilds({
|
||||
query: query.q,
|
||||
const response = await adminService.searchService.searchGuilds({
|
||||
query: query.q,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.SEARCH_GUILDS,
|
||||
metadata: {
|
||||
has_query: query.q === undefined ? undefined : true,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
}),
|
||||
);
|
||||
result_count: response.guilds.length,
|
||||
total: response.total,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -133,11 +146,15 @@ export function GuildAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.lookupService.lookupGuild({
|
||||
guild_id: ctx.req.valid('param').guild_id,
|
||||
}),
|
||||
);
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const response = await adminService.guildServiceAggregate.lookupService.lookupGuild({guild_id});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guild_id,
|
||||
action: AdminAuditReadActions.GET_GUILD,
|
||||
metadata: {found: response.guild !== null},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
@@ -215,6 +232,16 @@ export function GuildAdminController(app: HonoApp) {
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
const appliedFieldGroup =
|
||||
body.fields !== undefined ||
|
||||
hasGuildSettingsUpdate(body) ||
|
||||
hasGuildFeatureUpdate(body) ||
|
||||
body.name !== undefined ||
|
||||
body.vanity_url_code !== undefined ||
|
||||
body.new_owner_id !== undefined;
|
||||
if (!appliedFieldGroup) {
|
||||
await recordAdminWrite(ctx, {targetType: 'guild', targetId: guildIdRaw, action: 'update_guild'});
|
||||
}
|
||||
return ctx.json({
|
||||
guild: {
|
||||
id: guild.id,
|
||||
@@ -275,14 +302,25 @@ export function GuildAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const query = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.lookupService.listGuildMembers({
|
||||
guild_id: ctx.req.valid('param').guild_id,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
}),
|
||||
);
|
||||
const response = await adminService.guildServiceAggregate.lookupService.listGuildMembers({
|
||||
guild_id,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guild_id,
|
||||
action: AdminAuditReadActions.LIST_GUILD_MEMBERS,
|
||||
metadata: {
|
||||
limit: response.limit,
|
||||
offset: response.offset,
|
||||
result_count: response.members.length,
|
||||
total: response.total,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
@@ -391,7 +429,14 @@ export function GuildAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
return ctx.json(await adminService.guildServiceAggregate.lookupService.listGuildEmojis(guildId));
|
||||
const response = await adminService.guildServiceAggregate.lookupService.listGuildEmojis(guildId);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
action: AdminAuditReadActions.LIST_GUILD_EMOJIS,
|
||||
metadata: {result_count: response.emojis.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -412,7 +457,14 @@ export function GuildAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
return ctx.json(await adminService.guildServiceAggregate.lookupService.listGuildStickers(guildId));
|
||||
const response = await adminService.guildServiceAggregate.lookupService.listGuildStickers(guildId);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
action: AdminAuditReadActions.LIST_GUILD_STICKERS,
|
||||
metadata: {result_count: response.stickers.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -433,17 +485,30 @@ export function GuildAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const query = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.listGuildAuditLogs({
|
||||
guild_id: ctx.req.valid('param').guild_id,
|
||||
const response = await adminService.guildServiceAggregate.listGuildAuditLogs({
|
||||
guild_id,
|
||||
limit: query.limit,
|
||||
before: query.before,
|
||||
after: query.after,
|
||||
user_id: query.user_id,
|
||||
action_type: query.action_type,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'guild',
|
||||
targetId: guild_id,
|
||||
action: AdminAuditReadActions.LIST_GUILD_AUDIT_LOGS,
|
||||
metadata: {
|
||||
limit: query.limit,
|
||||
before: query.before,
|
||||
after: query.after,
|
||||
user_id: query.user_id,
|
||||
filter_user_id: query.user_id,
|
||||
action_type: query.action_type,
|
||||
}),
|
||||
);
|
||||
result_count: response.audit_log_entries.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {
|
||||
@@ -31,14 +33,7 @@ import {
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {BlockedMessageGroupsConfigSchema} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {ExpressionInfoCardConfigSchema} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
|
||||
import {GuildActivityLogPresentationConfigSchema} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {GuildHeaderCollapseConfigSchema} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {MessageHoverTrackingConfigSchema} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
import {MessageKeyboardFocusConfigSchema} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
import {TypingIndicatorReworkConfigSchema} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas';
|
||||
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import type {Context} from 'hono';
|
||||
@@ -64,14 +59,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
guildActivityLogPresentation,
|
||||
experimentDelivery,
|
||||
messageHoverTracking,
|
||||
messageKeyboardFocus,
|
||||
blockedMessageGroups,
|
||||
expressionInfoCard,
|
||||
guildHeaderCollapse,
|
||||
typingIndicatorRework,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
pendingRegistrations,
|
||||
@@ -79,14 +67,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getGuildActivityLogPresentationConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getMessageHoverTrackingConfig(),
|
||||
instanceConfigRepository.getMessageKeyboardFocusConfig(),
|
||||
instanceConfigRepository.getBlockedMessageGroupsConfig(),
|
||||
instanceConfigRepository.getExpressionInfoCardConfig(),
|
||||
instanceConfigRepository.getGuildHeaderCollapseConfig(),
|
||||
instanceConfigRepository.getTypingIndicatorReworkConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
instanceConfigRepository.getPendingRegistrations(),
|
||||
@@ -117,14 +98,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
},
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
guild_activity_log_presentation: guildActivityLogPresentation,
|
||||
experiment_delivery: experimentDelivery,
|
||||
message_hover_tracking: messageHoverTracking,
|
||||
message_keyboard_focus: messageKeyboardFocus,
|
||||
blocked_message_groups: blockedMessageGroups,
|
||||
expression_info_card: expressionInfoCard,
|
||||
guild_header_collapse: guildHeaderCollapse,
|
||||
typing_indicator_rework: typingIndicatorRework,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
urls: registrationUrls,
|
||||
@@ -195,16 +169,25 @@ function completesInitialSetup(data: InstanceConfigUpdateRequest, setupConfigure
|
||||
);
|
||||
}
|
||||
|
||||
async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<void> {
|
||||
async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boolean> {
|
||||
const user = ctx.get('user');
|
||||
if (!user || ctx.get('authTokenType') !== 'session' || hasAdminAuthenticationACL(user.acls)) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const nextACLs = new Set(user.acls);
|
||||
nextACLs.add(AdminACLs.WILDCARD);
|
||||
const updatedUser = await ctx.get('userRepository').patchUpsert(user.id, {acls: nextACLs}, user.toRow());
|
||||
ctx.set('user', updatedUser);
|
||||
ctx.set('adminUserAcls', updatedUser.acls);
|
||||
return true;
|
||||
}
|
||||
|
||||
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
|
||||
const sections = Object.entries(data)
|
||||
.filter(([, value]) => value != null)
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
return sections.length > 0 ? sections.join(',') : undefined;
|
||||
}
|
||||
|
||||
export function InstanceConfigAdminController(app: HonoApp) {
|
||||
@@ -224,7 +207,17 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await buildInstanceConfigResponse());
|
||||
const response = await buildInstanceConfigResponse();
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'instance_config',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.GET_INSTANCE_CONFIG,
|
||||
metadata: {
|
||||
registration_url_count: response.registration.urls.length,
|
||||
pending_registration_count: response.registration.pending_registrations.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
@@ -268,91 +261,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.guild_activity_log_presentation) {
|
||||
const patch = omitUndefinedFields(data.guild_activity_log_presentation);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentGuildActivityLogPresentation =
|
||||
await instanceConfigRepository.getGuildActivityLogPresentationConfig();
|
||||
const validated = GuildActivityLogPresentationConfigSchema.parse({
|
||||
...currentGuildActivityLogPresentation,
|
||||
...patch,
|
||||
config_version: currentGuildActivityLogPresentation.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setGuildActivityLogPresentationConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.message_hover_tracking) {
|
||||
const patch = omitUndefinedFields(data.message_hover_tracking);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentMessageHoverTracking = await instanceConfigRepository.getMessageHoverTrackingConfig();
|
||||
const validated = MessageHoverTrackingConfigSchema.parse({
|
||||
...currentMessageHoverTracking,
|
||||
...patch,
|
||||
config_version: currentMessageHoverTracking.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setMessageHoverTrackingConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.message_keyboard_focus) {
|
||||
const patch = omitUndefinedFields(data.message_keyboard_focus);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentMessageKeyboardFocus = await instanceConfigRepository.getMessageKeyboardFocusConfig();
|
||||
const validated = MessageKeyboardFocusConfigSchema.parse({
|
||||
...currentMessageKeyboardFocus,
|
||||
...patch,
|
||||
config_version: currentMessageKeyboardFocus.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setMessageKeyboardFocusConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.blocked_message_groups) {
|
||||
const patch = omitUndefinedFields(data.blocked_message_groups);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentBlockedMessageGroups = await instanceConfigRepository.getBlockedMessageGroupsConfig();
|
||||
const validated = BlockedMessageGroupsConfigSchema.parse({
|
||||
...currentBlockedMessageGroups,
|
||||
...patch,
|
||||
config_version: currentBlockedMessageGroups.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setBlockedMessageGroupsConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.expression_info_card) {
|
||||
const patch = omitUndefinedFields(data.expression_info_card);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentExpressionInfoCard = await instanceConfigRepository.getExpressionInfoCardConfig();
|
||||
const validated = ExpressionInfoCardConfigSchema.parse({
|
||||
...currentExpressionInfoCard,
|
||||
...patch,
|
||||
config_version: currentExpressionInfoCard.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setExpressionInfoCardConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.guild_header_collapse) {
|
||||
const patch = omitUndefinedFields(data.guild_header_collapse);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentGuildHeaderCollapse = await instanceConfigRepository.getGuildHeaderCollapseConfig();
|
||||
const validated = GuildHeaderCollapseConfigSchema.parse({
|
||||
...currentGuildHeaderCollapse,
|
||||
...patch,
|
||||
config_version: currentGuildHeaderCollapse.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setGuildHeaderCollapseConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.typing_indicator_rework) {
|
||||
const patch = omitUndefinedFields(data.typing_indicator_rework);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentTypingIndicatorRework = await instanceConfigRepository.getTypingIndicatorReworkConfig();
|
||||
const validated = TypingIndicatorReworkConfigSchema.parse({
|
||||
...currentTypingIndicatorRework,
|
||||
...patch,
|
||||
config_version: currentTypingIndicatorRework.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setTypingIndicatorReworkConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
|
||||
const validated = ExperimentDeliveryConfigSchema.parse({
|
||||
@@ -418,6 +326,11 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
wordmark_url: readOptionalField(data.app_public.branding, 'wordmark_url'),
|
||||
favicon_url: readOptionalField(data.app_public.branding, 'favicon_url'),
|
||||
theme_color: readOptionalField(data.app_public.branding, 'theme_color'),
|
||||
status_page_url: readOptionalField(data.app_public.branding, 'status_page_url'),
|
||||
status_page_incident_history_url: readOptionalField(
|
||||
data.app_public.branding,
|
||||
'status_page_incident_history_url',
|
||||
),
|
||||
})
|
||||
: undefined,
|
||||
legal: data.app_public.legal
|
||||
@@ -519,10 +432,20 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
}),
|
||||
});
|
||||
}
|
||||
let grantedSetupCompleterAdmin = false;
|
||||
if (shouldGrantSetupCompleterAdmin) {
|
||||
await grantSetupCompleterAdminACL(ctx);
|
||||
grantedSetupCompleterAdmin = await grantSetupCompleterAdminACL(ctx);
|
||||
await instanceConfigRepository.markAdminBootstrapped();
|
||||
}
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'instance_config',
|
||||
targetId: 0n,
|
||||
action: 'update_instance_config',
|
||||
metadata: {
|
||||
sections: listSuppliedSections(data),
|
||||
granted_acls: grantedSetupCompleterAdmin ? AdminACLs.WILDCARD : undefined,
|
||||
},
|
||||
});
|
||||
return ctx.json(await buildInstanceConfigResponse());
|
||||
},
|
||||
);
|
||||
@@ -553,6 +476,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
});
|
||||
const brandingPatch: Partial<InstanceBranding> = {[`${kind}_url`]: prepared.newCdnUrl};
|
||||
await instanceConfigRepository.setAppPublicConfig({branding: brandingPatch});
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'instance_config',
|
||||
targetId: 0n,
|
||||
action: 'upload_branding_asset',
|
||||
metadata: {kind, cleared: prepared.newCdnUrl === null},
|
||||
});
|
||||
return ctx.json(await buildInstanceConfigResponse());
|
||||
},
|
||||
);
|
||||
@@ -573,6 +502,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
let result: InstanceEmailSmtpTestResponse;
|
||||
try {
|
||||
const provider = new SmtpEmailProvider({
|
||||
host: data.host,
|
||||
@@ -585,10 +515,17 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
socketTimeoutMs: 10000,
|
||||
});
|
||||
await provider.verify();
|
||||
return ctx.json({ok: true, error: null});
|
||||
result = {ok: true, error: null};
|
||||
} catch (error) {
|
||||
return ctx.json({ok: false, error: error instanceof Error ? error.message : String(error)});
|
||||
result = {ok: false, error: error instanceof Error ? error.message : String(error)};
|
||||
}
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'instance_config',
|
||||
targetId: 0n,
|
||||
action: 'test_smtp_connection',
|
||||
metadata: {port: data.port, secure: data.secure, ok: result.ok},
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -615,6 +552,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
maxUses: data.max_uses ?? null,
|
||||
approvalRequired: data.approval_required,
|
||||
});
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'registration_url',
|
||||
targetId: 0n,
|
||||
action: 'create_registration_url',
|
||||
metadata: {approval_required: data.approval_required, max_uses: data.max_uses},
|
||||
});
|
||||
return ctx.json({
|
||||
registration_url: created.registrationUrl,
|
||||
code: created.code,
|
||||
@@ -639,6 +582,11 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
await instanceConfigRepository.revokeRegistrationUrl(ctx.req.valid('param').registration_url_id);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'registration_url',
|
||||
targetId: 0n,
|
||||
action: 'revoke_registration_url',
|
||||
});
|
||||
return ctx.json(await buildInstanceConfigResponse());
|
||||
},
|
||||
);
|
||||
@@ -755,6 +703,12 @@ async function updatePendingRegistrationUser(
|
||||
const userRepository = ctx.get('userRepository');
|
||||
const user = await userRepository.findUnique(createUserID(BigInt(userId)));
|
||||
if (!user) {
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: decision === 'approve' ? 'approve_registration' : 'reject_registration',
|
||||
metadata: {account_found: false},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const traits = new Set(user.traits);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -114,7 +115,15 @@ export function JobsAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.jobAdminService.cancelJob(ctx.req.valid('param').job_id));
|
||||
const {job_id} = ctx.req.valid('param');
|
||||
const result = await adminService.jobAdminService.cancelJob(job_id);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'bulk_job',
|
||||
targetId: job_id,
|
||||
action: 'cancel_job',
|
||||
metadata: {cancelled: result.cancelled},
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
@@ -10,7 +12,7 @@ import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {LIMIT_CATEGORY_LABELS, LIMIT_KEY_METADATA, LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
import type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
|
||||
import {LimitConfigGetResponse, LimitConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
function formatConfig(service: LimitConfigService) {
|
||||
@@ -27,6 +29,21 @@ function formatConfig(service: LimitConfigService) {
|
||||
};
|
||||
}
|
||||
|
||||
function describeLimitRule(rule: LimitRule): string {
|
||||
return JSON.stringify([
|
||||
rule.filters?.traits ?? [],
|
||||
rule.filters?.guildFeatures ?? [],
|
||||
LIMIT_KEYS.map((key) => rule.limits[key] ?? null),
|
||||
]);
|
||||
}
|
||||
|
||||
function countChangedLimitRules(before: LimitConfigSnapshot, after: LimitConfigSnapshot): number {
|
||||
const previous = new Map(before.rules.map((rule) => [rule.id, describeLimitRule(rule)]));
|
||||
const next = new Map(after.rules.map((rule) => [rule.id, describeLimitRule(rule)]));
|
||||
const ruleIds = new Set([...previous.keys(), ...next.keys()]);
|
||||
return Array.from(ruleIds).filter((ruleId) => previous.get(ruleId) !== next.get(ruleId)).length;
|
||||
}
|
||||
|
||||
export function LimitConfigAdminController(app: HonoApp) {
|
||||
app.get(
|
||||
'/admin/limit-config',
|
||||
@@ -44,7 +61,14 @@ export function LimitConfigAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const limitConfigService = ctx.get('limitConfigService') as LimitConfigService;
|
||||
return ctx.json(formatConfig(limitConfigService));
|
||||
const response = formatConfig(limitConfigService);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'limit_config',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.GET_LIMIT_CONFIG,
|
||||
metadata: {rule_count: response.limit_config.rules.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
@@ -69,8 +93,20 @@ export function LimitConfigAdminController(app: HonoApp) {
|
||||
...data.limit_config,
|
||||
traitDefinitions: data.limit_config.traitDefinitions ?? [],
|
||||
};
|
||||
const previous = limitConfigService.getConfigSnapshot();
|
||||
await limitConfigService.updateConfig(normalized);
|
||||
return ctx.json(formatConfig(limitConfigService));
|
||||
const response = formatConfig(limitConfigService);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'limit_config',
|
||||
targetId: 0n,
|
||||
action: 'update_limit_config',
|
||||
metadata: {
|
||||
rule_count: response.limit_config.rules.length,
|
||||
changed_rule_count: countChangedLimitRules(previous, response.limit_config),
|
||||
trait_definition_count: response.limit_config.traitDefinitions.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createAttachmentID, createChannelID, createMessageID, createReportID} from '@app/api/BrandedTypes';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -57,34 +59,69 @@ export function MessageAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const query = ctx.req.valid('query');
|
||||
if (query.message_id != null) {
|
||||
return ctx.json(
|
||||
await adminService.messageService.lookupMessage({
|
||||
const messageId = query.message_id;
|
||||
const response = await adminService.messageService.lookupMessage({
|
||||
channel_id: query.channel_id,
|
||||
message_id: messageId,
|
||||
context_limit: query.context_limit,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'message',
|
||||
targetId: messageId,
|
||||
action: AdminAuditReadActions.SEARCH_MESSAGES,
|
||||
metadata: {
|
||||
mode: 'message',
|
||||
channel_id: query.channel_id,
|
||||
message_id: query.message_id,
|
||||
context_limit: query.context_limit,
|
||||
}),
|
||||
);
|
||||
found: response.messages.some((message) => message.id === messageId.toString()),
|
||||
result_count: response.messages.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
}
|
||||
if (query.attachment_id != null) {
|
||||
if (query.filename == null) {
|
||||
throw InputValidationError.fromCode('filename', ValidationErrorCodes.INVALID_FORMAT);
|
||||
}
|
||||
return ctx.json(
|
||||
await adminService.messageService.lookupMessageByAttachment({
|
||||
channel_id: query.channel_id,
|
||||
attachment_id: query.attachment_id,
|
||||
filename: query.filename,
|
||||
context_limit: query.context_limit,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return ctx.json(
|
||||
await adminService.messageService.searchChannelMessages({
|
||||
const response = await adminService.messageService.lookupMessageByAttachment({
|
||||
channel_id: query.channel_id,
|
||||
query: query.q ?? '',
|
||||
attachment_id: query.attachment_id,
|
||||
filename: query.filename,
|
||||
context_limit: query.context_limit,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'channel',
|
||||
targetId: query.channel_id,
|
||||
action: AdminAuditReadActions.SEARCH_MESSAGES,
|
||||
metadata: {
|
||||
mode: 'attachment',
|
||||
attachment_id: query.attachment_id,
|
||||
message_id: response.message_id,
|
||||
context_limit: query.context_limit,
|
||||
found: response.message_id !== null,
|
||||
result_count: response.messages.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
}
|
||||
const response = await adminService.messageService.searchChannelMessages({
|
||||
channel_id: query.channel_id,
|
||||
query: query.q ?? '',
|
||||
limit: query.limit,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'channel',
|
||||
targetId: query.channel_id,
|
||||
action: AdminAuditReadActions.SEARCH_MESSAGES,
|
||||
metadata: {
|
||||
mode: 'search',
|
||||
has_query: query.q === undefined ? undefined : true,
|
||||
limit: query.limit,
|
||||
}),
|
||||
);
|
||||
result_count: response.messages.length,
|
||||
total: response.total,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -139,7 +176,14 @@ export function MessageAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {job_id} = ctx.req.valid('param');
|
||||
return ctx.json(await adminService.messageShredService.getMessageShredStatus(job_id.toString()));
|
||||
const response = await adminService.messageShredService.getMessageShredStatus(job_id.toString());
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'message_shred',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.GET_MESSAGE_SHRED_STATUS,
|
||||
metadata: {job_id, status: response.status},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -162,14 +206,25 @@ export function MessageAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {channel_id} = ctx.req.valid('param');
|
||||
const {limit, before, after} = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.messageService.browseChannel({
|
||||
channel_id,
|
||||
const response = await adminService.messageService.browseChannel({
|
||||
channel_id,
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'channel',
|
||||
targetId: channel_id,
|
||||
action: AdminAuditReadActions.LIST_CHANNEL_MESSAGES,
|
||||
metadata: {
|
||||
limit,
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
}),
|
||||
);
|
||||
result_count: response.messages.length,
|
||||
has_more: response.has_more,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -192,13 +247,23 @@ export function MessageAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const {context_limit} = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.messageService.lookupMessage({
|
||||
const response = await adminService.messageService.lookupMessage({
|
||||
channel_id,
|
||||
message_id,
|
||||
context_limit,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'message',
|
||||
targetId: message_id,
|
||||
action: AdminAuditReadActions.GET_MESSAGE,
|
||||
metadata: {
|
||||
channel_id,
|
||||
message_id,
|
||||
context_limit,
|
||||
}),
|
||||
);
|
||||
found: response.messages.some((message) => message.id === message_id.toString()),
|
||||
result_count: response.messages.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createReportID} from '@app/api/BrandedTypes';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
@@ -88,15 +90,39 @@ export function ReportAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const query = ctx.req.valid('query');
|
||||
if (query.status === undefined || usesReportSearchIndex(query)) {
|
||||
return ctx.json(
|
||||
await adminService.reportServiceAggregate.searchReports(toSearchReportsRequest(query), adminUserAcls),
|
||||
);
|
||||
}
|
||||
const status = REPORT_STATUS_BY_FILTER[query.status];
|
||||
return ctx.json(
|
||||
await adminService.reportServiceAggregate.listReports(status, adminUserAcls, query.limit, query.offset),
|
||||
);
|
||||
const status = query.status;
|
||||
const searched = status === undefined || usesReportSearchIndex(query);
|
||||
const response = searched
|
||||
? await adminService.reportServiceAggregate.searchReports(toSearchReportsRequest(query), adminUserAcls)
|
||||
: await adminService.reportServiceAggregate.listReports(
|
||||
REPORT_STATUS_BY_FILTER[status],
|
||||
adminUserAcls,
|
||||
query.limit,
|
||||
query.offset,
|
||||
);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'report',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.SEARCH_REPORTS,
|
||||
metadata: {
|
||||
has_query: query.q === undefined ? undefined : true,
|
||||
status,
|
||||
report_type: query.report_type,
|
||||
reporter_user_id: query.reporter_id,
|
||||
reported_user_id: query.reported_user_id,
|
||||
reported_guild_id: query.reported_guild_id,
|
||||
reported_channel_id: query.reported_channel_id,
|
||||
context_guild_id: query.guild_context_id,
|
||||
resolved_by_admin_user_id: query.resolved_by_admin_id,
|
||||
sort_by: searched ? query.sort_by : undefined,
|
||||
sort_order: searched ? query.sort_order : undefined,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
result_count: response.reports.length,
|
||||
total: response.total,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -119,6 +145,15 @@ export function ReportAdminController(app: HonoApp) {
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const report = await adminService.reportServiceAggregate.getReport(createReportID(report_id), adminUserAcls);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'report',
|
||||
targetId: report_id,
|
||||
action: AdminAuditReadActions.GET_REPORT,
|
||||
metadata: {
|
||||
report_type: report.report_type,
|
||||
status: report.status,
|
||||
},
|
||||
});
|
||||
return ctx.json(report);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {Readable} from 'node:stream';
|
||||
import * as v8 from 'node:v8';
|
||||
import {recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -28,11 +29,17 @@ export function SystemAdminController(app: HonoApp) {
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async () => {
|
||||
async (ctx) => {
|
||||
const snapshotPath = path.join('/tmp', `heap-${Date.now()}.heapsnapshot`);
|
||||
try {
|
||||
v8.writeHeapSnapshot(snapshotPath);
|
||||
const stat = fs.statSync(snapshotPath);
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'system',
|
||||
targetId: 0n,
|
||||
action: 'create_heap_snapshot',
|
||||
metadata: {size_bytes: stat.size},
|
||||
});
|
||||
const nodeStream = fs.createReadStream(snapshotPath);
|
||||
const body = Readable.toWeb(nodeStream) as ReadableStream;
|
||||
nodeStream.on('close', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
@@ -72,7 +74,14 @@ export function UserAdminController(app: HonoApp) {
|
||||
'Returns every access control permission the admin API recognises. This is the registry admin accounts and admin API keys draw their permissions from. Requires AUTHENTICATE permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json({acls: Object.values(AdminACLs)});
|
||||
const acls = Object.values(AdminACLs);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'admin_acl',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_ADMIN_ACLS,
|
||||
metadata: {acl_count: acls.length},
|
||||
});
|
||||
return ctx.json({acls});
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -127,25 +136,50 @@ export function UserAdminController(app: HonoApp) {
|
||||
throw inputValidationErrorFromZodIssues(parsed.error.issues);
|
||||
}
|
||||
const {users} = await adminService.userService.lookupService.lookupUser(parsed.data, adminUserAcls);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.SEARCH_USERS,
|
||||
metadata: {
|
||||
selector: userIds ? 'user_id' : 'resolve',
|
||||
user_id: userIds?.length === 1 ? userIds[0] : undefined,
|
||||
user_id_count: userIds?.length,
|
||||
result_count: users.length,
|
||||
total: users.length,
|
||||
},
|
||||
});
|
||||
return ctx.json({users, total: users.length});
|
||||
}
|
||||
if (query.email?.trim()) {
|
||||
const selector = query.email?.trim() ? 'email' : query.last_active_ip?.trim() ? 'last_active_ip' : 'search';
|
||||
if (selector === 'email') {
|
||||
requireSelectorACL(adminUserAcls, AdminACLs.USER_VIEW_EMAIL);
|
||||
} else if (query.last_active_ip?.trim()) {
|
||||
} else if (selector === 'last_active_ip') {
|
||||
requireSelectorACL(adminUserAcls, AdminACLs.USER_VIEW_IP);
|
||||
}
|
||||
return ctx.json(
|
||||
await adminService.searchService.searchUsers(
|
||||
{
|
||||
query: query.q,
|
||||
email: query.email,
|
||||
last_active_ip: query.last_active_ip,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
},
|
||||
adminUserAcls,
|
||||
),
|
||||
const response = await adminService.searchService.searchUsers(
|
||||
{
|
||||
query: query.q,
|
||||
email: query.email,
|
||||
last_active_ip: query.last_active_ip,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
},
|
||||
adminUserAcls,
|
||||
);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.SEARCH_USERS,
|
||||
metadata: {
|
||||
selector,
|
||||
has_query: selector === 'search' && query.q?.trim() ? true : undefined,
|
||||
limit: selector === 'email' ? undefined : query.limit,
|
||||
offset: selector === 'email' ? undefined : query.offset,
|
||||
result_count: response.users.length,
|
||||
total: response.total,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -167,7 +201,14 @@ export function UserAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(await adminService.userService.lookupService.lookupUser({user_ids: [userId]}, adminUserAcls));
|
||||
const response = await adminService.userService.lookupService.lookupUser({user_ids: [userId]}, adminUserAcls);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: AdminAuditReadActions.GET_USER,
|
||||
metadata: {found: response.users.length > 0},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -190,9 +231,23 @@ export function UserAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
const query = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.lookupService.listUserGuilds({user_id: userId, ...query}),
|
||||
);
|
||||
const response = await adminService.guildServiceAggregate.lookupService.listUserGuilds({
|
||||
user_id: userId,
|
||||
...query,
|
||||
});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: AdminAuditReadActions.LIST_USER_GUILDS,
|
||||
metadata: {
|
||||
before_guild_id: query.before,
|
||||
after_guild_id: query.after,
|
||||
limit: query.limit,
|
||||
with_counts: query.with_counts,
|
||||
guild_count: response.guilds.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -216,9 +271,29 @@ export function UserAdminController(app: HonoApp) {
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
const {type, ...pagination} = ctx.req.valid('query');
|
||||
if (type === 'group_dm') {
|
||||
return ctx.json(await adminService.userService.listUserGroupDmChannels({user_id: userId}));
|
||||
const response = await adminService.userService.listUserGroupDmChannels({user_id: userId});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: AdminAuditReadActions.LIST_USER_DM_CHANNELS,
|
||||
metadata: {type, channel_count: response.channels.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
}
|
||||
return ctx.json(await adminService.userService.listUserDmChannels({user_id: userId, ...pagination}));
|
||||
const response = await adminService.userService.listUserDmChannels({user_id: userId, ...pagination});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: AdminAuditReadActions.LIST_USER_DM_CHANNELS,
|
||||
metadata: {
|
||||
type,
|
||||
before_channel_id: pagination.before,
|
||||
after_channel_id: pagination.after,
|
||||
limit: pagination.limit,
|
||||
channel_count: response.channels.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -242,7 +317,18 @@ export function UserAdminController(app: HonoApp) {
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
const query = ctx.req.valid('query');
|
||||
return ctx.json(await adminService.userService.listUserChangeLog({user_id: userId, ...query}, adminUserAcls));
|
||||
const response = await adminService.userService.listUserChangeLog({user_id: userId, ...query}, adminUserAcls);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: AdminAuditReadActions.LIST_USER_CHANGE_LOG,
|
||||
metadata: {
|
||||
limit: query.limit,
|
||||
has_page_token: query.page_token === undefined ? undefined : true,
|
||||
entry_count: response.entries.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -263,7 +349,19 @@ export function UserAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(await adminService.relationshipService.listRelationships({user_id: userId}));
|
||||
const response = await adminService.relationshipService.listRelationships({user_id: userId});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: AdminAuditReadActions.LIST_USER_RELATIONSHIPS,
|
||||
metadata: {
|
||||
friend_count: response.friends.length,
|
||||
incoming_request_count: response.incoming_requests.length,
|
||||
outgoing_request_count: response.outgoing_requests.length,
|
||||
blocked_count: response.blocked.length,
|
||||
},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -53,7 +55,15 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.voiceService.listVoiceRegions(ctx.req.valid('query')));
|
||||
const query = ctx.req.valid('query');
|
||||
const response = await adminService.voiceService.listVoiceRegions(query);
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'voice_region',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_VOICE_REGIONS,
|
||||
metadata: {include_servers: query.include_servers, result_count: response.regions.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -98,12 +108,16 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(
|
||||
await adminService.voiceService.getVoiceRegion({
|
||||
id: ctx.req.valid('param').region_id,
|
||||
include_servers: ctx.req.valid('query').include_servers,
|
||||
}),
|
||||
);
|
||||
const {region_id} = ctx.req.valid('param');
|
||||
const {include_servers} = ctx.req.valid('query');
|
||||
const response = await adminService.voiceService.getVoiceRegion({id: region_id, include_servers});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'voice_region',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.GET_VOICE_REGION,
|
||||
metadata: {region_id, include_servers, found: response.region !== null},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
@@ -182,7 +196,15 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.voiceService.listVoiceServers({region_id: ctx.req.valid('param').region_id}));
|
||||
const {region_id} = ctx.req.valid('param');
|
||||
const response = await adminService.voiceService.listVoiceServers({region_id});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'voice_server',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_VOICE_SERVERS,
|
||||
metadata: {region_id, result_count: response.servers.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
@@ -234,7 +256,14 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {region_id, server_id} = ctx.req.valid('param');
|
||||
return ctx.json(await adminService.voiceService.getVoiceServer({region_id, server_id}));
|
||||
const response = await adminService.voiceService.getVoiceServer({region_id, server_id});
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'voice_server',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.GET_VOICE_SERVER,
|
||||
metadata: {region_id, server_id, found: response.server !== null},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ADMIN_AUDIT_READ_ACTIONS, getAdminAuditAccess} from '@app/api/admin/AdminAuditActions';
|
||||
import type {AdminAuditLog, IAdminRepository} from '@app/api/admin/IAdminRepository';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {createChannelID, createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
@@ -12,7 +13,9 @@ import type {Guild} from '@app/api/models/Guild';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {getAuditLogSearchService} from '@app/api/SearchFactory';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import type {AuditLogSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {
|
||||
AdminAuditAccess,
|
||||
AdminAuditLogChannelSummary,
|
||||
AdminAuditLogGuildSummary,
|
||||
AdminAuditLogResponse,
|
||||
@@ -75,6 +78,7 @@ export class AdminAuditService {
|
||||
admin_user_id?: bigint;
|
||||
target_type?: string;
|
||||
target_id?: string;
|
||||
access?: AdminAuditAccess;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<AuditLogsListResponse> {
|
||||
@@ -85,20 +89,21 @@ export class AdminAuditService {
|
||||
adminUserId: data.admin_user_id,
|
||||
targetType: data.target_type,
|
||||
targetId: targetIdBigInt,
|
||||
access: data.access,
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
});
|
||||
}
|
||||
const limit = data.limit || 50;
|
||||
const filters: Record<string, string> = {};
|
||||
const filters: AuditLogSearchFilters = {...accessFilters(data.access)};
|
||||
if (data.admin_user_id) {
|
||||
filters['adminUserId'] = data.admin_user_id.toString();
|
||||
filters.adminUserId = data.admin_user_id.toString();
|
||||
}
|
||||
if (data.target_type) {
|
||||
filters['targetType'] = data.target_type;
|
||||
filters.targetType = data.target_type;
|
||||
}
|
||||
if (data.target_id) {
|
||||
filters['targetId'] = data.target_id;
|
||||
filters.targetId = data.target_id;
|
||||
}
|
||||
const {hits, total} = await auditLogSearchService.searchAuditLogs('', filters, {
|
||||
limit,
|
||||
@@ -116,6 +121,7 @@ export class AdminAuditService {
|
||||
admin_user_id?: bigint;
|
||||
target_type?: string;
|
||||
target_id?: string;
|
||||
access?: AdminAuditAccess;
|
||||
sort_by?: 'createdAt' | 'relevance';
|
||||
sort_order?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
@@ -128,25 +134,26 @@ export class AdminAuditService {
|
||||
adminUserId: data.admin_user_id,
|
||||
targetType: data.target_type,
|
||||
targetId: targetIdBigInt,
|
||||
access: data.access,
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
});
|
||||
}
|
||||
const filters: Record<string, string> = {};
|
||||
const filters: AuditLogSearchFilters = {...accessFilters(data.access)};
|
||||
if (data.admin_user_id) {
|
||||
filters['adminUserId'] = data.admin_user_id.toString();
|
||||
filters.adminUserId = data.admin_user_id.toString();
|
||||
}
|
||||
if (data.target_id) {
|
||||
filters['targetId'] = data.target_id;
|
||||
filters.targetId = data.target_id;
|
||||
}
|
||||
if (data.target_type) {
|
||||
filters['targetType'] = data.target_type;
|
||||
filters.targetType = data.target_type;
|
||||
}
|
||||
if (data.sort_by) {
|
||||
filters['sortBy'] = data.sort_by;
|
||||
filters.sortBy = data.sort_by;
|
||||
}
|
||||
if (data.sort_order) {
|
||||
filters['sortOrder'] = data.sort_order;
|
||||
filters.sortOrder = data.sort_order;
|
||||
}
|
||||
const {hits, total} = await auditLogSearchService.searchAuditLogs(data.query || '', filters, {
|
||||
limit: data.limit || 50,
|
||||
@@ -163,6 +170,7 @@ export class AdminAuditService {
|
||||
adminUserId?: bigint;
|
||||
targetType?: string;
|
||||
targetId?: bigint;
|
||||
access?: AdminAuditAccess;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<AuditLogsListResponse> {
|
||||
@@ -178,6 +186,9 @@ export class AdminAuditService {
|
||||
if (data.targetId) {
|
||||
filteredLogs = filteredLogs.filter((log) => log.targetId === data.targetId);
|
||||
}
|
||||
if (data.access) {
|
||||
filteredLogs = filteredLogs.filter((log) => getAdminAuditAccess(log.action) === data.access);
|
||||
}
|
||||
const offset = data.offset || 0;
|
||||
const paginatedLogs = filteredLogs.slice(offset, offset + limit);
|
||||
return {
|
||||
@@ -229,6 +240,7 @@ export class AdminAuditService {
|
||||
[...enrichment.channels.entries()].map(([id, channel]) => [id, mapChannelSummary(channel)!]),
|
||||
),
|
||||
action: log.action,
|
||||
access: getAdminAuditAccess(log.action),
|
||||
audit_log_reason: log.auditLogReason,
|
||||
metadata: Object.fromEntries(log.metadata),
|
||||
created_at: log.createdAt.toISOString(),
|
||||
@@ -318,6 +330,12 @@ export class AdminAuditService {
|
||||
}
|
||||
}
|
||||
|
||||
function accessFilters(access: AdminAuditAccess | undefined): AuditLogSearchFilters {
|
||||
if (access === 'read') return {actions: [...ADMIN_AUDIT_READ_ACTIONS]};
|
||||
if (access === 'write') return {excludeActions: [...ADMIN_AUDIT_READ_ACTIONS]};
|
||||
return {};
|
||||
}
|
||||
|
||||
interface AuditLogEnrichmentDeps {
|
||||
userRepository?: Pick<IUserRepository, 'findUnique'>;
|
||||
guildRepository?: Pick<IGuildRepositoryAggregate, 'findUnique'>;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ADMIN_AUDIT_READ_ACTIONS, getAdminAuditAccess} from '@app/api/admin/AdminAuditActions';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {getAuditLogSearchService} from '@app/api/SearchFactory';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {AuditLogsListResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {afterEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const TARGET_USER_ID = 910000000000000001n;
|
||||
|
||||
describe('Admin audit log access filter', () => {
|
||||
let harness: ApiTestHarness | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
harness = undefined;
|
||||
});
|
||||
|
||||
async function createAuditViewer(activeHarness: ApiTestHarness): Promise<TestAccount> {
|
||||
return setUserACLs(activeHarness, await createTestAccount(activeHarness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.AUDIT_LOG_VIEW,
|
||||
]);
|
||||
}
|
||||
|
||||
async function seedEntry(admin: TestAccount, logId: bigint, action: string): Promise<void> {
|
||||
const log = await getAdminRepository().createAuditLog({
|
||||
log_id: logId,
|
||||
admin_user_id: createUserID(BigInt(admin.userId)),
|
||||
target_type: 'user',
|
||||
target_id: TARGET_USER_ID,
|
||||
action,
|
||||
audit_log_reason: null,
|
||||
metadata: new Map(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
await getAuditLogSearchService()?.indexAuditLog(log);
|
||||
}
|
||||
|
||||
async function listActions(
|
||||
activeHarness: ApiTestHarness,
|
||||
admin: TestAccount,
|
||||
query: string,
|
||||
): Promise<Array<{action: string; access: string}>> {
|
||||
const result = await createBuilder<AuditLogsListResponse>(activeHarness, admin.token)
|
||||
.get(`/admin/audit-logs?target_type=user&target_id=${TARGET_USER_ID}${query}`)
|
||||
.execute();
|
||||
return result.logs
|
||||
.map((log) => ({action: log.action, access: log.access}))
|
||||
.sort((a, b) => a.action.localeCompare(b.action));
|
||||
}
|
||||
|
||||
test('classifies every registered read action as a read and anything else as a write', () => {
|
||||
expect(ADMIN_AUDIT_READ_ACTIONS.length).toBe(new Set(ADMIN_AUDIT_READ_ACTIONS).size);
|
||||
for (const action of ADMIN_AUDIT_READ_ACTIONS) {
|
||||
expect(getAdminAuditAccess(action)).toBe('read');
|
||||
}
|
||||
expect(getAdminAuditAccess('update_flags')).toBe('write');
|
||||
expect(getAdminAuditAccess('NCMEC Report')).toBe('write');
|
||||
});
|
||||
|
||||
for (const search of ['disabled', 'enabled'] as const) {
|
||||
test(`filters entries by access with search ${search}`, async () => {
|
||||
harness = await createApiTestHarness({search});
|
||||
const admin = await createAuditViewer(harness);
|
||||
await seedEntry(admin, 910000000000000011n, 'get_user');
|
||||
await seedEntry(admin, 910000000000000012n, 'list_user_sessions');
|
||||
await seedEntry(admin, 910000000000000013n, 'update_flags');
|
||||
await seedEntry(admin, 910000000000000014n, 'temp_ban');
|
||||
|
||||
expect(await listActions(harness, admin, '')).toEqual([
|
||||
{action: 'get_user', access: 'read'},
|
||||
{action: 'list_user_sessions', access: 'read'},
|
||||
{action: 'temp_ban', access: 'write'},
|
||||
{action: 'update_flags', access: 'write'},
|
||||
]);
|
||||
expect(await listActions(harness, admin, '&access=read')).toEqual([
|
||||
{action: 'get_user', access: 'read'},
|
||||
{action: 'list_user_sessions', access: 'read'},
|
||||
]);
|
||||
expect(await listActions(harness, admin, '&access=write')).toEqual([
|
||||
{action: 'temp_ban', access: 'write'},
|
||||
{action: 'update_flags', access: 'write'},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
test('filters a full-text search by access', async () => {
|
||||
harness = await createApiTestHarness({search: 'enabled'});
|
||||
const admin = await createAuditViewer(harness);
|
||||
await seedEntry(admin, 910000000000000021n, 'list_user_sessions');
|
||||
await seedEntry(admin, 910000000000000022n, 'terminate_sessions');
|
||||
|
||||
expect(await listActions(harness, admin, '&q=sessions&access=write')).toEqual([
|
||||
{action: 'terminate_sessions', access: 'write'},
|
||||
]);
|
||||
expect(await listActions(harness, admin, '&q=sessions&access=read')).toEqual([
|
||||
{action: 'list_user_sessions', access: 'read'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects an unknown access value', async () => {
|
||||
harness = await createApiTestHarness();
|
||||
const admin = await createAuditViewer(harness);
|
||||
await createBuilder(harness, admin.token).get('/admin/audit-logs?access=delete').expect(400).execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAdminApiKey} from '@app/api/admin/tests/AdminTestUtils';
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {expect} from 'vitest';
|
||||
|
||||
export const AdminApiKeyAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/api-keys',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/api-keys',
|
||||
body: {name: 'Coverage key', acls: ['user:lookup', 'guild:lookup'], expires_in_days: 30},
|
||||
},
|
||||
expected: {
|
||||
action: 'create_admin_api_key',
|
||||
targetType: 'admin_api_key',
|
||||
targetId: expect.any(String),
|
||||
metadata: {acls: 'user:lookup,guild:lookup', expires_in_days: '30'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/api-keys',
|
||||
async prepare({harness, admin}) {
|
||||
await createAdminApiKey(harness, admin, 'First key', ['user:lookup'], null);
|
||||
await createAdminApiKey(harness, admin, 'Second key', ['guild:lookup'], 7);
|
||||
return {
|
||||
request: {path: '/admin/api-keys'},
|
||||
expected: {
|
||||
action: 'list_admin_api_keys',
|
||||
targetType: 'admin_api_key',
|
||||
targetId: '0',
|
||||
metadata: {result_count: '2'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/api-keys/:key_id',
|
||||
async prepare({harness, admin}) {
|
||||
const key = await createAdminApiKey(harness, admin, 'Readable key', ['user:lookup'], null);
|
||||
return {
|
||||
request: {path: `/admin/api-keys/${key.keyId}`},
|
||||
expected: {
|
||||
action: 'get_admin_api_key',
|
||||
targetType: 'admin_api_key',
|
||||
targetId: key.keyId,
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/api-keys/:key_id',
|
||||
name: 'name and acls',
|
||||
async prepare({harness, admin}) {
|
||||
const key = await createAdminApiKey(harness, admin, 'Old name', ['user:lookup'], null);
|
||||
return {
|
||||
request: {path: `/admin/api-keys/${key.keyId}`, body: {name: 'New name', acls: ['guild:lookup']}},
|
||||
expected: {
|
||||
action: 'update_admin_api_key',
|
||||
targetType: 'admin_api_key',
|
||||
targetId: key.keyId,
|
||||
metadata: {fields: 'name,acls', acls: 'guild:lookup'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/api-keys/:key_id',
|
||||
name: 'name only',
|
||||
async prepare({harness, admin}) {
|
||||
const key = await createAdminApiKey(harness, admin, 'Old name', ['user:lookup'], 30);
|
||||
return {
|
||||
request: {path: `/admin/api-keys/${key.keyId}`, body: {name: 'New name'}},
|
||||
expected: {
|
||||
action: 'update_admin_api_key',
|
||||
targetType: 'admin_api_key',
|
||||
targetId: key.keyId,
|
||||
metadata: {fields: 'name'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/api-keys/:key_id',
|
||||
async prepare({harness, admin}) {
|
||||
const key = await createAdminApiKey(harness, admin, 'Revoked key', ['user:lookup'], null);
|
||||
return {
|
||||
request: {path: `/admin/api-keys/${key.keyId}`},
|
||||
expected: {
|
||||
action: 'revoke_admin_api_key',
|
||||
targetType: 'admin_api_key',
|
||||
targetId: key.keyId,
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminApiKeyAdminAuditCases} from '@app/api/admin/tests/audit_coverage/AdminApiKeyAdminAuditCases';
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
|
||||
describeAdminAuditCoverage('AdminApiKeyAdminController', AdminApiKeyAdminAuditCases);
|
||||
@@ -0,0 +1,133 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getAdminAuditAccess} from '@app/api/admin/AdminAuditActions';
|
||||
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {afterEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const ADMIN_AUDIT_COVERAGE_REASON = 'Audit coverage ticket 5120';
|
||||
|
||||
type AdminRouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
export interface AdminAuditCoverageContext {
|
||||
harness: ApiTestHarness;
|
||||
admin: TestAccount;
|
||||
}
|
||||
|
||||
interface AdminAuditCoverageRequest {
|
||||
path: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
expectStatus?: number;
|
||||
}
|
||||
|
||||
interface AdminAuditExpectedEntry {
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: unknown;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AdminAuditCoverageCaseBase {
|
||||
method: AdminRouteMethod;
|
||||
route: string;
|
||||
name?: string;
|
||||
search?: 'disabled' | 'enabled';
|
||||
auditLogReason?: unknown;
|
||||
}
|
||||
|
||||
export interface AdminAuditAuditedCase extends AdminAuditCoverageCaseBase {
|
||||
prepare(
|
||||
context: AdminAuditCoverageContext,
|
||||
): Promise<{request: AdminAuditCoverageRequest; expected: AdminAuditExpectedEntry}>;
|
||||
}
|
||||
|
||||
export interface AdminAuditExemptCase extends AdminAuditCoverageCaseBase {
|
||||
exempt: true;
|
||||
prepare(context: AdminAuditCoverageContext): Promise<{request: AdminAuditCoverageRequest}>;
|
||||
}
|
||||
|
||||
export type AdminAuditCoverageCase = AdminAuditAuditedCase | AdminAuditExemptCase;
|
||||
|
||||
export function adminAuditCaseShape(coverageCase: AdminAuditCoverageCase): string {
|
||||
return `${coverageCase.method} ${coverageCase.route}`;
|
||||
}
|
||||
|
||||
function isExemptCase(coverageCase: AdminAuditCoverageCase): coverageCase is AdminAuditExemptCase {
|
||||
return 'exempt' in coverageCase && coverageCase.exempt;
|
||||
}
|
||||
|
||||
async function listAuditLogs(): Promise<Array<AdminAuditLog>> {
|
||||
return getAdminRepository().listAllAuditLogsPaginated(100000);
|
||||
}
|
||||
|
||||
function describeEntry(log: AdminAuditLog) {
|
||||
return {
|
||||
action: log.action,
|
||||
targetType: log.targetType,
|
||||
targetId: log.targetId.toString(),
|
||||
metadata: Object.fromEntries(log.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
export function describeAdminAuditCoverage(area: string, cases: ReadonlyArray<AdminAuditCoverageCase>): void {
|
||||
describe(`Admin audit coverage: ${area}`, () => {
|
||||
let harness: ApiTestHarness | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
harness = undefined;
|
||||
});
|
||||
|
||||
for (const coverageCase of cases) {
|
||||
const label = coverageCase.name
|
||||
? `${adminAuditCaseShape(coverageCase)} (${coverageCase.name})`
|
||||
: adminAuditCaseShape(coverageCase);
|
||||
test(label, async () => {
|
||||
harness = await createApiTestHarness({search: coverageCase.search ?? 'disabled'});
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]);
|
||||
const prepared = await coverageCase.prepare({harness, admin});
|
||||
const {request} = prepared;
|
||||
const before = new Set((await listAuditLogs()).map((log) => log.logId.toString()));
|
||||
const response = await harness.requestJson({
|
||||
path: request.path,
|
||||
method: coverageCase.method,
|
||||
body: request.body,
|
||||
headers: {
|
||||
Authorization: admin.token,
|
||||
'X-Audit-Log-Reason': ADMIN_AUDIT_COVERAGE_REASON,
|
||||
...request.headers,
|
||||
},
|
||||
});
|
||||
const expectedStatus = request.expectStatus ?? 200;
|
||||
if (response.status !== expectedStatus) {
|
||||
throw new Error(`Expected ${expectedStatus}, got ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
await response.arrayBuffer();
|
||||
const recorded = (await listAuditLogs()).filter((log) => !before.has(log.logId.toString()));
|
||||
|
||||
if (isExemptCase(coverageCase)) {
|
||||
expect(recorded.map(describeEntry)).toEqual([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const {expected} = prepared as Awaited<ReturnType<AdminAuditAuditedCase['prepare']>>;
|
||||
const expectedAccess = coverageCase.method === 'GET' ? 'read' : 'write';
|
||||
expect(getAdminAuditAccess(expected.action)).toBe(expectedAccess);
|
||||
for (const log of recorded) {
|
||||
expect(log.adminUserId.toString()).toBe(admin.userId);
|
||||
expect(log.auditLogReason).toEqual(coverageCase.auditLogReason ?? ADMIN_AUDIT_COVERAGE_REASON);
|
||||
expect(getAdminAuditAccess(log.action)).toBe(expectedAccess);
|
||||
}
|
||||
if (coverageCase.method === 'GET') {
|
||||
expect(recorded.map(describeEntry)).toEqual([expected]);
|
||||
} else {
|
||||
expect(recorded.map(describeEntry)).toContainEqual(expected);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {registerAdminControllers} from '@app/api/admin/controllers/index';
|
||||
import {AdminApiKeyAdminAuditCases} from '@app/api/admin/tests/audit_coverage/AdminApiKeyAdminAuditCases';
|
||||
import {adminAuditCaseShape} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {ApplicationAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ApplicationAdminAuditCases';
|
||||
import {ArchiveAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ArchiveAdminAuditCases';
|
||||
import {AssetAdminAuditCases} from '@app/api/admin/tests/audit_coverage/AssetAdminAuditCases';
|
||||
import {AuditLogAdminAuditCases} from '@app/api/admin/tests/audit_coverage/AuditLogAdminAuditCases';
|
||||
import {BanAdminAuditCases} from '@app/api/admin/tests/audit_coverage/BanAdminAuditCases';
|
||||
import {BulkAdminAuditCases} from '@app/api/admin/tests/audit_coverage/BulkAdminAuditCases';
|
||||
import {CodesAdminAuditCases} from '@app/api/admin/tests/audit_coverage/CodesAdminAuditCases';
|
||||
import {DiscoveryAdminAuditCases} from '@app/api/admin/tests/audit_coverage/DiscoveryAdminAuditCases';
|
||||
import {GatewayAdminAuditCases} from '@app/api/admin/tests/audit_coverage/GatewayAdminAuditCases';
|
||||
import {GuildAdminAuditCases} from '@app/api/admin/tests/audit_coverage/GuildAdminAuditCases';
|
||||
import {InstanceConfigAdminAuditCases} from '@app/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases';
|
||||
import {JobsAdminAuditCases} from '@app/api/admin/tests/audit_coverage/JobsAdminAuditCases';
|
||||
import {LimitConfigAdminAuditCases} from '@app/api/admin/tests/audit_coverage/LimitConfigAdminAuditCases';
|
||||
import {MessageAdminAuditCases} from '@app/api/admin/tests/audit_coverage/MessageAdminAuditCases';
|
||||
import {ReportAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ReportAdminAuditCases';
|
||||
import {SearchAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SearchAdminAuditCases';
|
||||
import {SystemAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SystemAdminAuditCases';
|
||||
import {SystemDmAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SystemDmAdminAuditCases';
|
||||
import {UserAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserAdminAuditCases';
|
||||
import {UserWriteAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserWriteAdminAuditCases';
|
||||
import {VoiceAdminAuditCases} from '@app/api/admin/tests/audit_coverage/VoiceAdminAuditCases';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const ALL_CASES = [
|
||||
...AdminApiKeyAdminAuditCases,
|
||||
...ApplicationAdminAuditCases,
|
||||
...ArchiveAdminAuditCases,
|
||||
...AssetAdminAuditCases,
|
||||
...AuditLogAdminAuditCases,
|
||||
...BanAdminAuditCases,
|
||||
...BulkAdminAuditCases,
|
||||
...CodesAdminAuditCases,
|
||||
...DiscoveryAdminAuditCases,
|
||||
...GatewayAdminAuditCases,
|
||||
...GuildAdminAuditCases,
|
||||
...InstanceConfigAdminAuditCases,
|
||||
...JobsAdminAuditCases,
|
||||
...LimitConfigAdminAuditCases,
|
||||
...MessageAdminAuditCases,
|
||||
...ReportAdminAuditCases,
|
||||
...SearchAdminAuditCases,
|
||||
...SystemAdminAuditCases,
|
||||
...SystemDmAdminAuditCases,
|
||||
...UserAdminAuditCases,
|
||||
...UserWriteAdminAuditCases,
|
||||
...VoiceAdminAuditCases,
|
||||
];
|
||||
|
||||
const EXEMPT_ROUTES = [
|
||||
'GET /admin/jobs',
|
||||
'GET /admin/jobs/:job_id',
|
||||
'GET /admin/jobs/active',
|
||||
'GET /admin/search/index-refreshes/:job_id',
|
||||
'GET /admin/users/@me',
|
||||
];
|
||||
|
||||
function registeredAdminRoutes(): Array<string> {
|
||||
const app = new Hono<HonoEnv>();
|
||||
registerAdminControllers(app);
|
||||
return [
|
||||
...new Set(app.routes.filter((route) => route.method !== 'ALL').map((route) => `${route.method} ${route.path}`)),
|
||||
].sort();
|
||||
}
|
||||
|
||||
describe('Admin audit route coverage', () => {
|
||||
test('every registered admin route has an audit coverage case', () => {
|
||||
expect([...new Set(ALL_CASES.map(adminAuditCaseShape))].sort()).toEqual(registeredAdminRoutes());
|
||||
});
|
||||
|
||||
test('only the identity and polled status reads are exempt from auditing', () => {
|
||||
const exempt = ALL_CASES.filter((coverageCase) => 'exempt' in coverageCase).map(adminAuditCaseShape);
|
||||
expect([...new Set(exempt)].sort()).toEqual(EXEMPT_ROUTES);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {createOAuth2Application, createUniqueApplicationName} from '@app/api/oauth/tests/OAuth2TestUtils';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
const MISSING_APPLICATION_ID = '999999999999999999';
|
||||
|
||||
export const ApplicationAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/applications',
|
||||
name: 'owner_id',
|
||||
async prepare({harness}) {
|
||||
const owner = await createTestAccount(harness);
|
||||
await createOAuth2Application(harness, owner.token, {name: createUniqueApplicationName('Audit Owned App')});
|
||||
return {
|
||||
request: {path: `/admin/applications?owner_id=${owner.userId}`},
|
||||
expected: {
|
||||
action: 'list_user_applications',
|
||||
targetType: 'user',
|
||||
targetId: owner.userId,
|
||||
metadata: {application_count: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/applications',
|
||||
name: 'guild_id',
|
||||
async prepare({harness}) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Audit Application Guild');
|
||||
const app = await createOAuth2Application(harness, owner.token, {
|
||||
name: createUniqueApplicationName('Audit Guild App'),
|
||||
bot_public: true,
|
||||
});
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/oauth2/authorize/consent')
|
||||
.body({
|
||||
client_id: app.application.id,
|
||||
scope: 'bot',
|
||||
guild_id: guild.id,
|
||||
permissions: Permissions.SEND_MESSAGES.toString(),
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return {
|
||||
request: {path: `/admin/applications?guild_id=${guild.id}`},
|
||||
expected: {
|
||||
action: 'list_guild_applications',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {application_count: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/:user_id/applications',
|
||||
async prepare({harness}) {
|
||||
const owner = await createTestAccount(harness);
|
||||
await createOAuth2Application(harness, owner.token, {name: createUniqueApplicationName('Audit First App')});
|
||||
await createOAuth2Application(harness, owner.token, {name: createUniqueApplicationName('Audit Second App')});
|
||||
return {
|
||||
request: {path: `/admin/users/${owner.userId}/applications`},
|
||||
expected: {
|
||||
action: 'list_user_applications',
|
||||
targetType: 'user',
|
||||
targetId: owner.userId,
|
||||
metadata: {application_count: '2'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/applications/:application_id',
|
||||
name: 'found',
|
||||
async prepare({harness}) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const app = await createOAuth2Application(harness, owner.token, {
|
||||
name: createUniqueApplicationName('Audit Lookup App'),
|
||||
});
|
||||
return {
|
||||
request: {path: `/admin/applications/${app.application.id}`},
|
||||
expected: {
|
||||
action: 'get_application',
|
||||
targetType: 'application',
|
||||
targetId: app.application.id,
|
||||
metadata: {
|
||||
found: 'true',
|
||||
owner_user_id: owner.userId,
|
||||
bot_user_id: app.botUserId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/applications/:application_id',
|
||||
name: 'missing',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: `/admin/applications/${MISSING_APPLICATION_ID}`},
|
||||
expected: {
|
||||
action: 'get_application',
|
||||
targetType: 'application',
|
||||
targetId: MISSING_APPLICATION_ID,
|
||||
metadata: {found: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/applications/:application_id',
|
||||
async prepare({harness}) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const newOwner = await createTestAccount(harness);
|
||||
const app = await createOAuth2Application(harness, owner.token, {
|
||||
name: createUniqueApplicationName('Audit Transferred App'),
|
||||
});
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/applications/${app.application.id}`,
|
||||
body: {new_owner_id: newOwner.userId},
|
||||
},
|
||||
expected: {
|
||||
action: 'transfer_ownership',
|
||||
targetType: 'application',
|
||||
targetId: app.application.id,
|
||||
metadata: {
|
||||
old_owner_id: owner.userId,
|
||||
new_owner_id: newOwner.userId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {ApplicationAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ApplicationAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('ApplicationAdminController', ApplicationAdminAuditCases);
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminArchiveRepository} from '@app/api/admin/repositories/AdminArchiveRepository';
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestGuild} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {expect} from 'vitest';
|
||||
|
||||
const MISSING_ARCHIVE_ID = '800000000000000201';
|
||||
|
||||
async function createGuildArchive(
|
||||
harness: ApiTestHarness,
|
||||
admin: TestAccount,
|
||||
): Promise<{guildId: string; archiveId: string}> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createTestGuild(harness, owner.token);
|
||||
const archive = await createBuilder<{archive_id: string}>(harness, admin.token)
|
||||
.post(`/admin/guilds/${guild.id}/archives`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return {guildId: guild.id, archiveId: archive.archive_id};
|
||||
}
|
||||
|
||||
export const ArchiveAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/users/:user_id/archives',
|
||||
async prepare({harness}) {
|
||||
const subject = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${subject.userId}/archives`, body: {include_attachments: true}},
|
||||
expected: {
|
||||
action: 'trigger_user_archive',
|
||||
targetType: 'user',
|
||||
targetId: subject.userId,
|
||||
metadata: {archive_id: expect.any(String), include_attachments: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/guilds/:guild_id/archives',
|
||||
async prepare({harness}) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createTestGuild(harness, owner.token);
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/archives`, body: {}},
|
||||
expected: {
|
||||
action: 'trigger_guild_archive',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {archive_id: expect.any(String), include_attachments: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/archives',
|
||||
name: 'by subject',
|
||||
async prepare({harness, admin}) {
|
||||
const {guildId} = await createGuildArchive(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/archives?subject_type=guild&subject_id=${guildId}&limit=10`},
|
||||
expected: {
|
||||
action: 'list_archives',
|
||||
targetType: 'archive',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
subject_type: 'guild',
|
||||
subject_guild_id: guildId,
|
||||
limit: '10',
|
||||
include_expired: 'false',
|
||||
result_count: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/archives',
|
||||
name: 'by requester',
|
||||
async prepare({harness, admin}) {
|
||||
await createGuildArchive(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/archives?requested_by=${admin.userId}&include_expired=true`},
|
||||
expected: {
|
||||
action: 'list_archives',
|
||||
targetType: 'archive',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
subject_type: 'all',
|
||||
requested_by_user_id: admin.userId,
|
||||
limit: '50',
|
||||
include_expired: 'true',
|
||||
result_count: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/archives/:subject_type/:subject_id/:archive_id',
|
||||
name: 'found',
|
||||
async prepare({harness, admin}) {
|
||||
const {guildId, archiveId} = await createGuildArchive(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/archives/guild/${guildId}/${archiveId}`},
|
||||
expected: {
|
||||
action: 'get_archive',
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
metadata: {archive_id: archiveId, found: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/archives/:subject_type/:subject_id/:archive_id',
|
||||
name: 'missing',
|
||||
async prepare({harness}) {
|
||||
const subject = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/archives/user/${subject.userId}/${MISSING_ARCHIVE_ID}`},
|
||||
expected: {
|
||||
action: 'get_archive',
|
||||
targetType: 'user',
|
||||
targetId: subject.userId,
|
||||
metadata: {archive_id: MISSING_ARCHIVE_ID, found: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/archives/:subject_type/:subject_id/:archive_id/download',
|
||||
async prepare({harness, admin}) {
|
||||
const {guildId, archiveId} = await createGuildArchive(harness, admin);
|
||||
const repository = new AdminArchiveRepository();
|
||||
const queued = await repository.findBySubjectAndArchiveId('guild', BigInt(guildId), BigInt(archiveId));
|
||||
if (!queued) {
|
||||
throw new Error(`Archive ${archiveId} not found`);
|
||||
}
|
||||
const started = await repository.markAsStarted(queued);
|
||||
await repository.markAsCompleted(
|
||||
started,
|
||||
`test/${archiveId}.zip`,
|
||||
1024n,
|
||||
new Date(Date.now() + 6 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
return {
|
||||
request: {path: `/admin/archives/guild/${guildId}/${archiveId}/download`},
|
||||
expected: {
|
||||
action: 'get_archive_download_url',
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
metadata: {archive_id: archiveId},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {ArchiveAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ArchiveAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('ArchiveAdminController', ArchiveAdminAuditCases);
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createGuild} from '@app/api/message/tests/MessageTestUtils';
|
||||
|
||||
const UNKNOWN_ASSET_ID = '900000000000000002';
|
||||
|
||||
export const AssetAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/guilds/:guild_id/assets',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Asset Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/assets`, body: {ids: [UNKNOWN_ASSET_ID]}},
|
||||
expected: {
|
||||
action: 'purge_asset',
|
||||
targetType: 'asset',
|
||||
targetId: UNKNOWN_ASSET_ID,
|
||||
metadata: {asset_type: 'unknown'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {AssetAdminAuditCases} from '@app/api/admin/tests/audit_coverage/AssetAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('AssetAdminController', AssetAdminAuditCases);
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
|
||||
const SEEDED_LOG_ID = 800000000000000001n;
|
||||
|
||||
async function seedWriteEntry(adminUserId: string): Promise<void> {
|
||||
await getAdminRepository().createAuditLog({
|
||||
log_id: SEEDED_LOG_ID,
|
||||
admin_user_id: createUserID(BigInt(adminUserId)),
|
||||
target_type: 'user',
|
||||
target_id: BigInt(adminUserId),
|
||||
action: 'update_flags',
|
||||
audit_log_reason: null,
|
||||
metadata: new Map(),
|
||||
created_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
export const AuditLogAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/audit-logs',
|
||||
name: 'list',
|
||||
async prepare({admin}) {
|
||||
await seedWriteEntry(admin.userId);
|
||||
return {
|
||||
request: {path: `/admin/audit-logs?access=write&target_id=${admin.userId}&limit=10`},
|
||||
expected: {
|
||||
action: 'list_audit_logs',
|
||||
targetType: 'audit_log',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
filter_target_id: admin.userId,
|
||||
access: 'write',
|
||||
limit: '10',
|
||||
offset: '0',
|
||||
result_count: '1',
|
||||
total: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/audit-logs',
|
||||
name: 'search',
|
||||
search: 'enabled',
|
||||
async prepare({admin}) {
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/audit-logs?q=flags&admin_user_id=${admin.userId}&target_type=user&access=read&sort_by=relevance`,
|
||||
},
|
||||
expected: {
|
||||
action: 'search_audit_logs',
|
||||
targetType: 'audit_log',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
filter_admin_user_id: admin.userId,
|
||||
filter_target_type: 'user',
|
||||
access: 'read',
|
||||
sort_by: 'relevance',
|
||||
sort_order: 'desc',
|
||||
limit: '50',
|
||||
offset: '0',
|
||||
result_count: '0',
|
||||
total: '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/audit-logs',
|
||||
name: 'free-text target type',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: `/admin/audit-logs?target_type=${encodeURIComponent('[email protected]')}`},
|
||||
expected: {
|
||||
action: 'list_audit_logs',
|
||||
targetType: 'audit_log',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
has_target_type_filter: 'true',
|
||||
limit: '50',
|
||||
offset: '0',
|
||||
result_count: '0',
|
||||
total: '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/audit-logs/:log_id',
|
||||
async prepare({admin}) {
|
||||
await seedWriteEntry(admin.userId);
|
||||
return {
|
||||
request: {path: `/admin/audit-logs/${SEEDED_LOG_ID}`},
|
||||
expected: {
|
||||
action: 'get_audit_log',
|
||||
targetType: 'audit_log',
|
||||
targetId: SEEDED_LOG_ID.toString(),
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {AuditLogAdminAuditCases} from '@app/api/admin/tests/audit_coverage/AuditLogAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('AuditLogAdminController', AuditLogAdminAuditCases);
|
||||
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
AdminAuditCoverageCase,
|
||||
AdminAuditCoverageContext,
|
||||
} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {updateAvatar} from '@app/api/user/tests/UserTestUtils';
|
||||
import {expect} from 'vitest';
|
||||
|
||||
const FILE_SHA = 'a1'.repeat(32);
|
||||
const SECOND_FILE_SHA = 'b2'.repeat(32);
|
||||
const AVATAR_HASH = '0123abcd';
|
||||
|
||||
async function addBlocklistEntry(
|
||||
{harness, admin}: AdminAuditCoverageContext,
|
||||
listType: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, admin.token)
|
||||
.post(`/admin/blocklists/${listType}/entries`)
|
||||
.body(body)
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export const BanAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/blocklists',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/blocklists'},
|
||||
expected: {
|
||||
action: 'list_blocklists',
|
||||
targetType: 'blocklist',
|
||||
targetId: '0',
|
||||
metadata: {result_count: '9'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/blocklists/:list_type/entries',
|
||||
name: 'file-sha',
|
||||
async prepare(context) {
|
||||
await addBlocklistEntry(context, 'file-sha', {sha256_hex: FILE_SHA});
|
||||
await addBlocklistEntry(context, 'file-sha', {sha256_hex: SECOND_FILE_SHA});
|
||||
return {
|
||||
request: {path: '/admin/blocklists/file-sha/entries?limit=1'},
|
||||
expected: {
|
||||
action: 'list_blocklist_entries',
|
||||
targetType: 'file_sha',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
list_type: 'file-sha',
|
||||
limit: '1',
|
||||
result_count: '1',
|
||||
has_more: 'true',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/blocklists/:list_type/entries',
|
||||
name: 'profile-substring after a cursor',
|
||||
async prepare(context) {
|
||||
await addBlocklistEntry(context, 'profile-substring', {scope: 'bio', substrings: ['alpha', 'beta']});
|
||||
return {
|
||||
request: {path: '/admin/blocklists/profile-substring/entries?scope=bio&after=alpha'},
|
||||
expected: {
|
||||
action: 'list_blocklist_entries',
|
||||
targetType: 'profile_substring',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
list_type: 'profile-substring',
|
||||
scope: 'bio',
|
||||
limit: '50',
|
||||
has_after: 'true',
|
||||
result_count: '1',
|
||||
has_more: 'false',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/blocklists/:list_type/entries',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/blocklists/file-sha/entries',
|
||||
body: {sha256_hex: FILE_SHA},
|
||||
expectStatus: 204,
|
||||
},
|
||||
expected: {
|
||||
action: 'ban_file_sha',
|
||||
targetType: 'file_sha',
|
||||
targetId: '0',
|
||||
metadata: {sha256: FILE_SHA},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/blocklists/:list_type/entries',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/blocklists/file-sha/entries',
|
||||
body: {sha256_list: [FILE_SHA, SECOND_FILE_SHA]},
|
||||
},
|
||||
expected: {
|
||||
action: 'queue_bulk_job',
|
||||
targetType: 'bulk_job',
|
||||
targetId: expect.any(String),
|
||||
metadata: {task: 'ban_file_shas', entity_count: '2'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/blocklists/:list_type/entries',
|
||||
async prepare(context) {
|
||||
await addBlocklistEntry(context, 'avatar-hash', {hashes: [AVATAR_HASH]});
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/blocklists/avatar-hash/entries',
|
||||
body: {hashes: [AVATAR_HASH]},
|
||||
expectStatus: 204,
|
||||
},
|
||||
expected: {
|
||||
action: 'unban_avatar_hash',
|
||||
targetType: 'avatar_hash',
|
||||
targetId: '0',
|
||||
metadata: {hash_short: AVATAR_HASH},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/blocklists/:list_type/entries/:entry_value',
|
||||
name: 'file-sha banned',
|
||||
async prepare(context) {
|
||||
await addBlocklistEntry(context, 'file-sha', {sha256_hex: FILE_SHA});
|
||||
return {
|
||||
request: {path: `/admin/blocklists/file-sha/entries/${FILE_SHA}`},
|
||||
expected: {
|
||||
action: 'check_blocklist_entry',
|
||||
targetType: 'file_sha',
|
||||
targetId: '0',
|
||||
metadata: {list_type: 'file-sha', banned: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/blocklists/:list_type/entries/:entry_value',
|
||||
name: 'profile-substring not banned',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/blocklists/profile-substring/entries/gamma?scope=username'},
|
||||
expected: {
|
||||
action: 'check_blocklist_entry',
|
||||
targetType: 'profile_substring',
|
||||
targetId: '0',
|
||||
metadata: {list_type: 'profile-substring', scope: 'username', banned: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/blocklists/:list_type/entries/:entry_value',
|
||||
async prepare(context) {
|
||||
await addBlocklistEntry(context, 'file-sha', {sha256_hex: FILE_SHA});
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/blocklists/file-sha/entries/${FILE_SHA}`,
|
||||
body: {severity: 3},
|
||||
expectStatus: 204,
|
||||
},
|
||||
expected: {
|
||||
action: 'ban_file_sha',
|
||||
targetType: 'file_sha',
|
||||
targetId: '0',
|
||||
metadata: {sha256: FILE_SHA},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/blocklists/:list_type/entries/:entry_value',
|
||||
async prepare(context) {
|
||||
await addBlocklistEntry(context, 'file-sha', {sha256_hex: FILE_SHA});
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/blocklists/file-sha/entries/${FILE_SHA}`,
|
||||
expectStatus: 204,
|
||||
},
|
||||
expected: {
|
||||
action: 'unban_file_sha',
|
||||
targetType: 'file_sha',
|
||||
targetId: '0',
|
||||
metadata: {sha256: FILE_SHA},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/users/:user_id/avatar-block',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const updated = await updateAvatar(harness, target.token, getPngDataUrl());
|
||||
const hashShort = (updated.avatar ?? '').toLowerCase().replace(/^a_/, '');
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/users/${target.userId}/avatar-block`,
|
||||
body: {reason: 'spam'},
|
||||
},
|
||||
expected: {
|
||||
action: 'ban_avatar_hash',
|
||||
targetType: 'avatar_hash',
|
||||
targetId: '0',
|
||||
metadata: {hash_short: hashShort, reason: 'spam'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {BanAdminAuditCases} from '@app/api/admin/tests/audit_coverage/BanAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('BanAdminController', BanAdminAuditCases);
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestGuild} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {expect} from 'vitest';
|
||||
|
||||
export const BulkAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/bulk-jobs',
|
||||
name: 'update_user_flags',
|
||||
async prepare({harness}) {
|
||||
const first = await createTestAccount(harness);
|
||||
const second = await createTestAccount(harness);
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/bulk-jobs',
|
||||
body: {task: 'update_user_flags', user_ids: [first.userId, second.userId], add_flags: [], remove_flags: []},
|
||||
},
|
||||
expected: {
|
||||
action: 'queue_bulk_job',
|
||||
targetType: 'bulk_job',
|
||||
targetId: expect.any(String),
|
||||
metadata: {task: 'update_user_flags', entity_count: '2'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/bulk-jobs',
|
||||
name: 'update_guild_features',
|
||||
async prepare({harness}) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createTestGuild(harness, owner.token);
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/bulk-jobs',
|
||||
body: {task: 'update_guild_features', guild_ids: [guild.id], add_features: [], remove_features: []},
|
||||
},
|
||||
expected: {
|
||||
action: 'queue_bulk_job',
|
||||
targetType: 'bulk_job',
|
||||
targetId: expect.any(String),
|
||||
metadata: {task: 'update_guild_features', entity_count: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/bulk-jobs',
|
||||
name: 'add_guild_members',
|
||||
async prepare({harness}) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createTestGuild(harness, owner.token);
|
||||
const member = await createTestAccount(harness);
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/bulk-jobs',
|
||||
body: {task: 'add_guild_members', guild_id: guild.id, user_ids: [member.userId]},
|
||||
},
|
||||
expected: {
|
||||
action: 'queue_bulk_job',
|
||||
targetType: 'bulk_job',
|
||||
targetId: expect.any(String),
|
||||
metadata: {task: 'add_guild_members', entity_count: '1', guild_id: guild.id},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {BulkAdminAuditCases} from '@app/api/admin/tests/audit_coverage/BulkAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('BulkAdminController', BulkAdminAuditCases);
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
|
||||
export const CodesAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/gift-codes',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/gift-codes',
|
||||
body: {count: 2, duration_type: 'months', duration_quantity: 3},
|
||||
},
|
||||
expected: {
|
||||
action: 'generate_gift_codes',
|
||||
targetType: 'gift_code',
|
||||
targetId: '0',
|
||||
metadata: {count: '2', duration_type: 'months', duration_quantity: '3'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {CodesAdminAuditCases} from '@app/api/admin/tests/audit_coverage/CodesAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('CodesAdminController', CodesAdminAuditCases);
|
||||
@@ -0,0 +1,230 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
AdminAuditCoverageCase,
|
||||
AdminAuditCoverageContext,
|
||||
} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuildID} from '@app/api/BrandedTypes';
|
||||
import {GuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
|
||||
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {
|
||||
DiscoveryApplicationStatus,
|
||||
DiscoveryCategories,
|
||||
type DiscoveryCategory,
|
||||
} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
|
||||
async function seedDiscoveryGuild(
|
||||
{harness}: AdminAuditCoverageContext,
|
||||
status: string,
|
||||
categoryType: DiscoveryCategory = DiscoveryCategories.GAMING,
|
||||
): Promise<string> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Audit Discovery Guild');
|
||||
const now = new Date();
|
||||
await new GuildDiscoveryRepository().upsert({
|
||||
guild_id: createGuildID(BigInt(guild.id)),
|
||||
status,
|
||||
category_type: categoryType,
|
||||
description: 'Audit coverage discovery listing',
|
||||
primary_language: null,
|
||||
custom_tags: [],
|
||||
applied_at: now,
|
||||
reviewed_at: status === DiscoveryApplicationStatus.APPROVED ? now : null,
|
||||
reviewed_by: null,
|
||||
review_reason: null,
|
||||
removed_at: null,
|
||||
removed_by: null,
|
||||
removal_reason: null,
|
||||
});
|
||||
return guild.id;
|
||||
}
|
||||
|
||||
export const DiscoveryAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/discovery/applications',
|
||||
async prepare(context) {
|
||||
await seedDiscoveryGuild(context, DiscoveryApplicationStatus.PENDING);
|
||||
await seedDiscoveryGuild(context, DiscoveryApplicationStatus.APPROVED);
|
||||
return {
|
||||
request: {path: '/admin/discovery/applications'},
|
||||
expected: {
|
||||
action: 'list_discovery_applications',
|
||||
targetType: 'guild',
|
||||
targetId: '0',
|
||||
metadata: {result_count: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/discovery/applications/:guild_id',
|
||||
name: 'approve',
|
||||
async prepare(context) {
|
||||
const guildId = await seedDiscoveryGuild(context, DiscoveryApplicationStatus.PENDING);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/discovery/applications/${guildId}`,
|
||||
body: {status: 'approved', reason: 'Meets every requirement'},
|
||||
},
|
||||
expected: {
|
||||
action: 'approve_discovery_application',
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
metadata: {status: 'approved'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/discovery/applications/:guild_id',
|
||||
name: 'reject',
|
||||
async prepare(context) {
|
||||
const guildId = await seedDiscoveryGuild(context, DiscoveryApplicationStatus.PENDING);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/discovery/applications/${guildId}`,
|
||||
body: {status: 'rejected', reason: 'Description is too vague'},
|
||||
},
|
||||
expected: {
|
||||
action: 'reject_discovery_application',
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
metadata: {status: 'rejected'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/discovery/categories',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/discovery/categories'},
|
||||
expected: {
|
||||
action: 'list_discovery_categories',
|
||||
targetType: 'discovery_category',
|
||||
targetId: '0',
|
||||
metadata: {result_count: '9'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/discovery/categories/:category_id/listings',
|
||||
async prepare(context) {
|
||||
await seedDiscoveryGuild(context, DiscoveryApplicationStatus.APPROVED, DiscoveryCategories.MUSIC);
|
||||
await seedDiscoveryGuild(context, DiscoveryApplicationStatus.APPROVED, DiscoveryCategories.MUSIC);
|
||||
await seedDiscoveryGuild(context, DiscoveryApplicationStatus.APPROVED, DiscoveryCategories.GAMING);
|
||||
return {
|
||||
request: {path: `/admin/discovery/categories/${DiscoveryCategories.MUSIC}/listings?limit=1&offset=1`},
|
||||
expected: {
|
||||
action: 'list_discovery_category_listings',
|
||||
targetType: 'discovery_category',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
category_id: DiscoveryCategories.MUSIC.toString(),
|
||||
limit: '1',
|
||||
offset: '1',
|
||||
result_count: '1',
|
||||
total: '2',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/discovery/listings',
|
||||
async prepare(context) {
|
||||
await seedDiscoveryGuild(context, DiscoveryApplicationStatus.APPROVED);
|
||||
await seedDiscoveryGuild(context, DiscoveryApplicationStatus.APPROVED);
|
||||
await seedDiscoveryGuild(context, DiscoveryApplicationStatus.PENDING);
|
||||
return {
|
||||
request: {path: '/admin/discovery/listings'},
|
||||
expected: {
|
||||
action: 'list_discovery_listings',
|
||||
targetType: 'guild',
|
||||
targetId: '0',
|
||||
metadata: {result_count: '2'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/discovery/listings',
|
||||
async prepare(context) {
|
||||
const guildId = await seedDiscoveryGuild(context, DiscoveryApplicationStatus.APPROVED);
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/discovery/listings',
|
||||
body: {guild_ids: [guildId], category_type: DiscoveryCategories.EDUCATION},
|
||||
},
|
||||
expected: {
|
||||
action: 'update_discovery_categories',
|
||||
targetType: 'guild',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
category_type: DiscoveryCategories.EDUCATION.toString(),
|
||||
guild_count: '1',
|
||||
updated: '1',
|
||||
failed: '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/discovery/listings/:guild_id',
|
||||
async prepare(context) {
|
||||
const guildId = await seedDiscoveryGuild(context, DiscoveryApplicationStatus.APPROVED);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/discovery/listings/${guildId}`,
|
||||
body: {
|
||||
description: 'A corrected discovery description',
|
||||
category_type: DiscoveryCategories.MUSIC,
|
||||
primary_language: 'fr',
|
||||
custom_tags: ['makers'],
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
action: 'update_discovery_listing',
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
metadata: {
|
||||
fields: 'description,category_type,primary_language,custom_tags',
|
||||
category_type: DiscoveryCategories.MUSIC.toString(),
|
||||
primary_language: 'fr',
|
||||
status: 'approved',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/discovery/listings/:guild_id',
|
||||
async prepare(context) {
|
||||
const guildId = await seedDiscoveryGuild(context, DiscoveryApplicationStatus.APPROVED);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/discovery/listings/${guildId}`,
|
||||
body: {reason: 'No longer meets the guidelines'},
|
||||
},
|
||||
expected: {
|
||||
action: 'remove_discovery_listing',
|
||||
targetType: 'guild',
|
||||
targetId: guildId,
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {DiscoveryAdminAuditCases} from '@app/api/admin/tests/audit_coverage/DiscoveryAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('DiscoveryAdminController', DiscoveryAdminAuditCases);
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
|
||||
export const GatewayAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/gateway/stats',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/gateway/stats'},
|
||||
expected: {
|
||||
action: 'get_gateway_stats',
|
||||
targetType: 'gateway',
|
||||
targetId: '0',
|
||||
metadata: {node_count: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/gateway/memory-stats',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/gateway/memory-stats?limit=250'},
|
||||
expected: {
|
||||
action: 'list_guild_memory_stats',
|
||||
targetType: 'guild',
|
||||
targetId: '0',
|
||||
metadata: {limit: '250', result_count: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/gateway/voice-state-counts',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/gateway/voice-state-counts'},
|
||||
expected: {
|
||||
action: 'get_voice_state_counts',
|
||||
targetType: 'gateway',
|
||||
targetId: '0',
|
||||
metadata: {total_voice_states: '0', region_count: '0', server_count: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/gateway/reloads',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Reload coverage guild');
|
||||
return {
|
||||
request: {path: '/admin/gateway/reloads', body: {guild_ids: [guild.id]}},
|
||||
expected: {
|
||||
action: 'reload_guilds',
|
||||
targetType: 'guild',
|
||||
targetId: '0',
|
||||
metadata: {guild_count: '1', reloaded: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {GatewayAdminAuditCases} from '@app/api/admin/tests/audit_coverage/GatewayAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('GatewayAdminController', GatewayAdminAuditCases);
|
||||
@@ -0,0 +1,292 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuild} from '@app/api/message/tests/MessageTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
|
||||
const MISSING_GUILD_ID = '900000000000000001';
|
||||
|
||||
async function addMember(
|
||||
harness: ApiTestHarness,
|
||||
admin: TestAccount,
|
||||
guildId: string,
|
||||
member: TestAccount,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, admin.token)
|
||||
.put(`/admin/guilds/${guildId}/members/${member.userId}`)
|
||||
.body(null)
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export const GuildAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/guilds',
|
||||
search: 'enabled',
|
||||
async prepare({harness, admin}) {
|
||||
const name = `Audit Search Guild ${Date.now()}`;
|
||||
await createGuild(harness, admin.token, name);
|
||||
return {
|
||||
request: {path: `/admin/guilds?q=${encodeURIComponent(name)}&limit=10`},
|
||||
expected: {
|
||||
action: 'search_guilds',
|
||||
targetType: 'guild',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
has_query: 'true',
|
||||
limit: '10',
|
||||
offset: '0',
|
||||
result_count: '1',
|
||||
total: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/guilds/:guild_id',
|
||||
name: 'found',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Detail Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}`},
|
||||
expected: {
|
||||
action: 'get_guild',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {found: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/guilds/:guild_id',
|
||||
name: 'missing',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: `/admin/guilds/${MISSING_GUILD_ID}`},
|
||||
expected: {
|
||||
action: 'get_guild',
|
||||
targetType: 'guild',
|
||||
targetId: MISSING_GUILD_ID,
|
||||
metadata: {found: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/guilds/:guild_id',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Patch Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}`, body: {name: 'Audit Patched Guild'}},
|
||||
expected: {
|
||||
action: 'update_name',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {old_name: 'Audit Patch Guild', new_name: 'Audit Patched Guild'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/guilds/:guild_id',
|
||||
name: 'empty body',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Empty Patch Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}`, body: {}},
|
||||
expected: {
|
||||
action: 'update_guild',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/guilds/:guild_id',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Deleted Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}`},
|
||||
expected: {
|
||||
action: 'delete_guild',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {guild_id: guild.id},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/guilds/:guild_id/members',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Members Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/members?limit=10&offset=5`},
|
||||
expected: {
|
||||
action: 'list_guild_members',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {limit: '10', offset: '5', result_count: '0', total: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/guilds/:guild_id/members/:user_id',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Force Add Guild');
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/members/${target.userId}`},
|
||||
expected: {
|
||||
action: 'force_add_to_guild',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {guild_id: guild.id},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/guilds/:guild_id/members/:user_id',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Kick Guild');
|
||||
const target = await createTestAccount(harness);
|
||||
await addMember(harness, admin, guild.id, target);
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/members/${target.userId}`, expectStatus: 204},
|
||||
expected: {
|
||||
action: 'kick_member',
|
||||
targetType: 'guild_member',
|
||||
targetId: target.userId,
|
||||
metadata: {guild_id: guild.id, user_id: target.userId},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/guilds/:guild_id/bans/:user_id',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Ban Guild');
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/bans/${target.userId}`, body: {}, expectStatus: 204},
|
||||
expected: {
|
||||
action: 'ban_member',
|
||||
targetType: 'guild_member',
|
||||
targetId: target.userId,
|
||||
metadata: {guild_id: guild.id, user_id: target.userId, delete_message_days: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/guilds/:guild_id/emojis',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Emoji Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/emojis`},
|
||||
expected: {
|
||||
action: 'list_guild_emojis',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {result_count: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/guilds/:guild_id/stickers',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Sticker Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/stickers`},
|
||||
expected: {
|
||||
action: 'list_guild_stickers',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {result_count: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/guilds/:guild_id/audit-logs',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Log Guild');
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({name: 'Audit Log Guild Renamed'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/guilds/${guild.id}/audit-logs?limit=10&user_id=${admin.userId}&action_type=${AuditLogActionType.GUILD_UPDATE}`,
|
||||
},
|
||||
expected: {
|
||||
action: 'list_guild_audit_logs',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {
|
||||
limit: '10',
|
||||
filter_user_id: admin.userId,
|
||||
action_type: String(AuditLogActionType.GUILD_UPDATE),
|
||||
result_count: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/guilds/:guild_id/reloads',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Reload Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/reloads`},
|
||||
expected: {
|
||||
action: 'reload_guild',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {guild_id: guild.id},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/guilds/:guild_id/shutdowns',
|
||||
async prepare({harness, admin}) {
|
||||
const guild = await createGuild(harness, admin.token, 'Audit Shutdown Guild');
|
||||
return {
|
||||
request: {path: `/admin/guilds/${guild.id}/shutdowns`},
|
||||
expected: {
|
||||
action: 'shutdown_guild',
|
||||
targetType: 'guild',
|
||||
targetId: guild.id,
|
||||
metadata: {guild_id: guild.id},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {GuildAdminAuditCases} from '@app/api/admin/tests/audit_coverage/GuildAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('GuildAdminController', GuildAdminAuditCases);
|
||||
@@ -0,0 +1,194 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
registerUser,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import type {CreateRegistrationUrlResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
|
||||
async function createRegistrationUrl(harness: ApiTestHarness, admin: TestAccount): Promise<string> {
|
||||
const created = await createBuilder<CreateRegistrationUrlResponse>(harness, admin.token)
|
||||
.post('/admin/instance/registration-urls')
|
||||
.body({approval_required: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return created.registration_url.id;
|
||||
}
|
||||
|
||||
export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/instance/config',
|
||||
name: 'admin acl',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegistrationUrl(harness, admin);
|
||||
return {
|
||||
request: {path: '/admin/instance/config'},
|
||||
expected: {
|
||||
action: 'get_instance_config',
|
||||
targetType: 'instance_config',
|
||||
targetId: '0',
|
||||
metadata: {registration_url_count: '1', pending_registration_count: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/instance/config',
|
||||
name: 'setup session',
|
||||
async prepare() {
|
||||
await getInstanceConfigRepository().setAppPublicConfig({setup: {configured: false}});
|
||||
return {
|
||||
request: {path: '/admin/instance/config'},
|
||||
expected: {
|
||||
action: 'get_instance_config',
|
||||
targetType: 'instance_config',
|
||||
targetId: '0',
|
||||
metadata: {registration_url_count: '0', pending_registration_count: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/instance/config',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/instance/config',
|
||||
body: {
|
||||
registration: {mode: 'approval'},
|
||||
experiment_delivery: {poll_interval_seconds: 600},
|
||||
sso: null,
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
action: 'update_instance_config',
|
||||
targetType: 'instance_config',
|
||||
targetId: '0',
|
||||
metadata: {sections: 'experiment_delivery,registration'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/instance/config/branding-assets',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/instance/config/branding-assets', body: {kind: 'icon', image: getPngDataUrl()}},
|
||||
expected: {
|
||||
action: 'upload_branding_asset',
|
||||
targetType: 'instance_config',
|
||||
targetId: '0',
|
||||
metadata: {kind: 'icon', cleared: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/instance/config/smtp-tests',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/instance/config/smtp-tests',
|
||||
body: {host: '127.0.0.1', port: 1, username: 'coverage', password: 'coverage-password', secure: false},
|
||||
},
|
||||
expected: {
|
||||
action: 'test_smtp_connection',
|
||||
targetType: 'instance_config',
|
||||
targetId: '0',
|
||||
metadata: {port: '1', secure: 'false', ok: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/instance/registration-urls',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/instance/registration-urls',
|
||||
body: {label: 'Coverage invite', max_uses: 5, approval_required: true},
|
||||
},
|
||||
expected: {
|
||||
action: 'create_registration_url',
|
||||
targetType: 'registration_url',
|
||||
targetId: '0',
|
||||
metadata: {approval_required: 'true', max_uses: '5'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/instance/registration-urls/:registration_url_id',
|
||||
async prepare({harness, admin}) {
|
||||
const registrationUrlId = await createRegistrationUrl(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/instance/registration-urls/${registrationUrlId}`},
|
||||
expected: {
|
||||
action: 'revoke_registration_url',
|
||||
targetType: 'registration_url',
|
||||
targetId: '0',
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/instance/pending-registrations/:user_id',
|
||||
async prepare({harness}) {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const pending = await registerUser(harness, {
|
||||
email: createUniqueEmail('coverage-pending'),
|
||||
username: createUniqueUsername('coveragepending'),
|
||||
global_name: 'Coverage Pending',
|
||||
password: 'coverage-pending-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
return {
|
||||
request: {path: `/admin/instance/pending-registrations/${pending.user_id}`, body: {status: 'approved'}},
|
||||
expected: {
|
||||
action: 'approve_registration',
|
||||
targetType: 'user',
|
||||
targetId: pending.user_id,
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/instance/pending-registrations/:user_id',
|
||||
name: 'missing account',
|
||||
async prepare() {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/instance/pending-registrations/${TEST_IDS.NONEXISTENT_USER}`,
|
||||
body: {status: 'rejected'},
|
||||
},
|
||||
expected: {
|
||||
action: 'reject_registration',
|
||||
targetType: 'user',
|
||||
targetId: TEST_IDS.NONEXISTENT_USER,
|
||||
metadata: {account_found: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {InstanceConfigAdminAuditCases} from '@app/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('InstanceConfigAdminController', InstanceConfigAdminAuditCases);
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {JobLedgerRepository} from '@app/api/jobs/JobLedgerRepository';
|
||||
|
||||
const LEDGER_JOB_ID = 800000000000000101n;
|
||||
|
||||
async function seedLedgerJob(requestedByUserId: string): Promise<void> {
|
||||
await new JobLedgerRepository().createJob({
|
||||
jobId: LEDGER_JOB_ID,
|
||||
taskType: 'bulkUpdateUserFlags',
|
||||
payload: {},
|
||||
requestedByUserId: BigInt(requestedByUserId),
|
||||
auditLogReason: null,
|
||||
maxAttempts: 5,
|
||||
runAt: null,
|
||||
jetStreamLane: 'lifecycle',
|
||||
jetStreamSeq: null,
|
||||
});
|
||||
}
|
||||
|
||||
export const JobsAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/jobs',
|
||||
exempt: true,
|
||||
async prepare({admin}) {
|
||||
await seedLedgerJob(admin.userId);
|
||||
return {request: {path: `/admin/jobs?limit=10&requested_by_user_id=${admin.userId}`}};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/jobs/active',
|
||||
exempt: true,
|
||||
async prepare({admin}) {
|
||||
await seedLedgerJob(admin.userId);
|
||||
return {request: {path: '/admin/jobs/active'}};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/jobs/:job_id',
|
||||
exempt: true,
|
||||
async prepare({admin}) {
|
||||
await seedLedgerJob(admin.userId);
|
||||
return {request: {path: `/admin/jobs/${LEDGER_JOB_ID}`}};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/jobs/:job_id/cancellation',
|
||||
async prepare({admin}) {
|
||||
await seedLedgerJob(admin.userId);
|
||||
return {
|
||||
request: {path: `/admin/jobs/${LEDGER_JOB_ID}/cancellation`, body: {}},
|
||||
expected: {
|
||||
action: 'cancel_job',
|
||||
targetType: 'bulk_job',
|
||||
targetId: LEDGER_JOB_ID.toString(),
|
||||
metadata: {cancelled: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {JobsAdminAuditCases} from '@app/api/admin/tests/audit_coverage/JobsAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('JobsAdminController', JobsAdminAuditCases);
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
|
||||
interface LimitConfigReadResponse {
|
||||
limit_config: LimitConfigSnapshot;
|
||||
}
|
||||
|
||||
async function readLimitConfig(harness: ApiTestHarness, admin: TestAccount): Promise<LimitConfigReadResponse> {
|
||||
return createBuilder<LimitConfigReadResponse>(harness, admin.token)
|
||||
.get('/admin/limit-config')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export const LimitConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/limit-config',
|
||||
async prepare({harness, admin}) {
|
||||
const current = await readLimitConfig(harness, admin);
|
||||
return {
|
||||
request: {path: '/admin/limit-config'},
|
||||
expected: {
|
||||
action: 'get_limit_config',
|
||||
targetType: 'limit_config',
|
||||
targetId: '0',
|
||||
metadata: {rule_count: String(current.limit_config.rules.length)},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/limit-config',
|
||||
async prepare({harness, admin}) {
|
||||
const current = await readLimitConfig(harness, admin);
|
||||
const rules = current.limit_config.rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
filters: rule.filters,
|
||||
limits: rule.id === 'default' ? {...rule.limits, max_guilds: (rule.limits.max_guilds ?? 0) + 1} : rule.limits,
|
||||
}));
|
||||
const traitDefinitions = [...current.limit_config.traitDefinitions, 'coverage'];
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/limit-config',
|
||||
body: {
|
||||
limit_config: {
|
||||
traitDefinitions,
|
||||
rules: [...rules, {id: 'coverage', filters: {traits: ['coverage']}, limits: {max_guilds: 5}}],
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
action: 'update_limit_config',
|
||||
targetType: 'limit_config',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
rule_count: String(rules.length + 1),
|
||||
changed_rule_count: '2',
|
||||
trait_definition_count: String(traitDefinitions.length),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {LimitConfigAdminAuditCases} from '@app/api/admin/tests/audit_coverage/LimitConfigAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('LimitConfigAdminController', LimitConfigAdminAuditCases);
|
||||
@@ -0,0 +1,266 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannel, createGuild} from '@app/api/channel/tests/AttachmentTestUtils';
|
||||
import {markChannelAsIndexed, sendMessage} from '@app/api/message/tests/MessageTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createMessageWithImageAttachment} from '@app/api/user/tests/FavoriteMemeTestUtils';
|
||||
import {expect} from 'vitest';
|
||||
|
||||
const UNKNOWN_ATTACHMENT_ID = '900000000000000003';
|
||||
const UNKNOWN_SHRED_JOB_ID = '900000000000000004';
|
||||
|
||||
async function createAuthorChannel(harness: ApiTestHarness): Promise<{author: TestAccount; channelId: string}> {
|
||||
const author = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, author.token, 'Audit Message Guild');
|
||||
const channel = await createChannel(harness, author.token, guild.id, 'audit-messages');
|
||||
return {author, channelId: channel.id};
|
||||
}
|
||||
|
||||
export const MessageAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/messages',
|
||||
name: 'search',
|
||||
search: 'enabled',
|
||||
async prepare({harness}) {
|
||||
const {author, channelId} = await createAuthorChannel(harness);
|
||||
await markChannelAsIndexed(harness, channelId);
|
||||
await sendMessage(harness, author.token, channelId, 'quarterly harbour report');
|
||||
return {
|
||||
request: {path: `/admin/messages?channel_id=${channelId}&q=harbour&limit=10`},
|
||||
expected: {
|
||||
action: 'search_messages',
|
||||
targetType: 'channel',
|
||||
targetId: channelId,
|
||||
metadata: {mode: 'search', has_query: 'true', limit: '10', result_count: '1', total: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/messages',
|
||||
name: 'message',
|
||||
async prepare({harness}) {
|
||||
const {author, channelId} = await createAuthorChannel(harness);
|
||||
const message = await sendMessage(harness, author.token, channelId, 'lookup target');
|
||||
return {
|
||||
request: {path: `/admin/messages?channel_id=${channelId}&message_id=${message.id}&context_limit=10`},
|
||||
expected: {
|
||||
action: 'search_messages',
|
||||
targetType: 'message',
|
||||
targetId: message.id,
|
||||
metadata: {
|
||||
mode: 'message',
|
||||
channel_id: channelId,
|
||||
context_limit: '10',
|
||||
found: 'true',
|
||||
result_count: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/messages',
|
||||
name: 'attachment',
|
||||
async prepare({harness}) {
|
||||
const {author, channelId} = await createAuthorChannel(harness);
|
||||
const message = await createMessageWithImageAttachment(harness, author.token, channelId);
|
||||
const attachment = message.attachments[0];
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/messages?channel_id=${channelId}&attachment_id=${attachment.id}&filename=${encodeURIComponent(attachment.filename)}`,
|
||||
},
|
||||
expected: {
|
||||
action: 'search_messages',
|
||||
targetType: 'channel',
|
||||
targetId: channelId,
|
||||
metadata: {
|
||||
mode: 'attachment',
|
||||
attachment_id: attachment.id,
|
||||
message_id: message.id,
|
||||
context_limit: '50',
|
||||
found: 'true',
|
||||
result_count: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/messages',
|
||||
name: 'attachment not found',
|
||||
async prepare({harness}) {
|
||||
const {channelId} = await createAuthorChannel(harness);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/messages?channel_id=${channelId}&attachment_id=${UNKNOWN_ATTACHMENT_ID}&filename=missing.png`,
|
||||
},
|
||||
expected: {
|
||||
action: 'search_messages',
|
||||
targetType: 'channel',
|
||||
targetId: channelId,
|
||||
metadata: {
|
||||
mode: 'attachment',
|
||||
attachment_id: UNKNOWN_ATTACHMENT_ID,
|
||||
context_limit: '50',
|
||||
found: 'false',
|
||||
result_count: '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/messages/ncmec-reports',
|
||||
auditLogReason: expect.stringMatching(/^NCMEC Report \S+ - \S+$/),
|
||||
async prepare({harness}) {
|
||||
const {author, channelId} = await createAuthorChannel(harness);
|
||||
const message = await createMessageWithImageAttachment(harness, author.token, channelId);
|
||||
const attachment = message.attachments[0];
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/messages/ncmec-reports',
|
||||
body: {
|
||||
channel_id: channelId,
|
||||
message_id: message.id,
|
||||
attachment_id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
reporter_full_name: 'Audit Coverage Reporter',
|
||||
confirmed_viewed: true,
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
action: 'NCMEC Report',
|
||||
targetType: 'user',
|
||||
targetId: author.userId,
|
||||
metadata: {
|
||||
channel_id: channelId,
|
||||
message_id: message.id,
|
||||
attachment_id: attachment.id,
|
||||
reported_user_email: author.email,
|
||||
reported_user_email_verified: 'true',
|
||||
reported_user_date_of_birth: '2000-01-01',
|
||||
reported_user_last_active_ip: '127.0.0.1',
|
||||
attachment_upload_mode: 'form_data',
|
||||
attachment_upload_request_ip: '127.0.0.1',
|
||||
attachment_upload_requested_at: expect.any(String),
|
||||
attachment_upload_ip_source: 'attachment upload request handled directly by Fluxer API',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/messages/shreds/:job_id',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: `/admin/messages/shreds/${UNKNOWN_SHRED_JOB_ID}`},
|
||||
expected: {
|
||||
action: 'get_message_shred_status',
|
||||
targetType: 'message_shred',
|
||||
targetId: '0',
|
||||
metadata: {job_id: UNKNOWN_SHRED_JOB_ID, status: 'not_found'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/channels/:channel_id/messages',
|
||||
async prepare({harness}) {
|
||||
const {author, channelId} = await createAuthorChannel(harness);
|
||||
const first = await sendMessage(harness, author.token, channelId, 'first');
|
||||
await sendMessage(harness, author.token, channelId, 'second');
|
||||
await sendMessage(harness, author.token, channelId, 'third');
|
||||
return {
|
||||
request: {path: `/admin/channels/${channelId}/messages?limit=1&after=${first.id}`},
|
||||
expected: {
|
||||
action: 'list_channel_messages',
|
||||
targetType: 'channel',
|
||||
targetId: channelId,
|
||||
metadata: {limit: '1', after: first.id, result_count: '1', has_more: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/channels/:channel_id/messages/:message_id',
|
||||
async prepare({harness}) {
|
||||
const {author, channelId} = await createAuthorChannel(harness);
|
||||
await sendMessage(harness, author.token, channelId, 'context before');
|
||||
const message = await sendMessage(harness, author.token, channelId, 'detail target');
|
||||
return {
|
||||
request: {path: `/admin/channels/${channelId}/messages/${message.id}`},
|
||||
expected: {
|
||||
action: 'get_message',
|
||||
targetType: 'message',
|
||||
targetId: message.id,
|
||||
metadata: {channel_id: channelId, context_limit: '50', found: 'true', result_count: '2'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/channels/:channel_id/messages/:message_id',
|
||||
async prepare({harness}) {
|
||||
const {author, channelId} = await createAuthorChannel(harness);
|
||||
const message = await sendMessage(harness, author.token, channelId, 'delete target');
|
||||
return {
|
||||
request: {path: `/admin/channels/${channelId}/messages/${message.id}`},
|
||||
expected: {
|
||||
action: 'delete_message',
|
||||
targetType: 'message',
|
||||
targetId: message.id,
|
||||
metadata: {channel_id: channelId, message_id: message.id},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/users/:user_id/message-shreds',
|
||||
async prepare({harness}) {
|
||||
const {author, channelId} = await createAuthorChannel(harness);
|
||||
const message = await sendMessage(harness, author.token, channelId, 'shred target');
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/users/${author.userId}/message-shreds`,
|
||||
body: {entries: [{channel_id: channelId, message_id: message.id}]},
|
||||
},
|
||||
expected: {
|
||||
action: 'queue_message_shred',
|
||||
targetType: 'message_shred',
|
||||
targetId: author.userId,
|
||||
metadata: {user_id: author.userId, job_id: expect.any(String), requested_entries: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/users/:user_id/messages',
|
||||
async prepare({harness}) {
|
||||
const {author, channelId} = await createAuthorChannel(harness);
|
||||
await sendMessage(harness, author.token, channelId, 'counted message');
|
||||
return {
|
||||
request: {path: `/admin/users/${author.userId}/messages`},
|
||||
expected: {
|
||||
action: 'delete_all_user_messages_dry_run',
|
||||
targetType: 'message_deletion',
|
||||
targetId: author.userId,
|
||||
metadata: {user_id: author.userId, channel_count: '1', message_count: '1', dry_run: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {MessageAdminAuditCases} from '@app/api/admin/tests/audit_coverage/MessageAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('MessageAdminController', MessageAdminAuditCases);
|
||||
@@ -0,0 +1,117 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
AdminAuditCoverageCase,
|
||||
AdminAuditCoverageContext,
|
||||
} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
|
||||
interface FiledUserReport {
|
||||
reportId: string;
|
||||
reportedUserId: string;
|
||||
}
|
||||
|
||||
async function fileUserReport({harness}: AdminAuditCoverageContext): Promise<FiledUserReport> {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const reported = await createTestAccount(harness);
|
||||
const report = await createBuilder<{report_id: string}>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({user_id: reported.userId, category: 'harassment'})
|
||||
.execute();
|
||||
return {reportId: report.report_id, reportedUserId: reported.userId};
|
||||
}
|
||||
|
||||
export const ReportAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/reports',
|
||||
name: 'search',
|
||||
search: 'enabled',
|
||||
async prepare(context) {
|
||||
const {reportedUserId} = await fileUserReport(context);
|
||||
await fileUserReport(context);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/reports?q=harassment&report_type=user&reported_user_id=${reportedUserId}&sort_by=created_at&sort_order=asc&limit=10`,
|
||||
},
|
||||
expected: {
|
||||
action: 'search_reports',
|
||||
targetType: 'report',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
has_query: 'true',
|
||||
report_type: 'user',
|
||||
reported_user_id: reportedUserId,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'asc',
|
||||
limit: '10',
|
||||
offset: '0',
|
||||
result_count: '1',
|
||||
total: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/reports',
|
||||
name: 'status',
|
||||
search: 'enabled',
|
||||
async prepare(context) {
|
||||
await fileUserReport(context);
|
||||
await fileUserReport(context);
|
||||
return {
|
||||
request: {path: '/admin/reports?status=pending&limit=1&offset=1'},
|
||||
expected: {
|
||||
action: 'search_reports',
|
||||
targetType: 'report',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
status: 'pending',
|
||||
limit: '1',
|
||||
offset: '1',
|
||||
result_count: '1',
|
||||
total: '2',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/reports/:report_id',
|
||||
async prepare(context) {
|
||||
const {reportId} = await fileUserReport(context);
|
||||
return {
|
||||
request: {path: `/admin/reports/${reportId}`},
|
||||
expected: {
|
||||
action: 'get_report',
|
||||
targetType: 'report',
|
||||
targetId: reportId,
|
||||
metadata: {report_type: '1', status: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/reports/:report_id',
|
||||
async prepare(context) {
|
||||
const {reportId} = await fileUserReport(context);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/reports/${reportId}`,
|
||||
body: {status: 'resolved', public_comment: 'We actioned the account.'},
|
||||
},
|
||||
expected: {
|
||||
action: 'resolve_report',
|
||||
targetType: 'report',
|
||||
targetId: reportId,
|
||||
metadata: {report_id: reportId, report_type: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {ReportAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ReportAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('ReportAdminController', ReportAdminAuditCases);
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {expect} from 'vitest';
|
||||
|
||||
export const SearchAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/search/indexes/:index_name/refreshes',
|
||||
async prepare({admin}) {
|
||||
return {
|
||||
request: {path: '/admin/search/indexes/favorite_memes/refreshes', body: {user_id: admin.userId}},
|
||||
expected: {
|
||||
action: 'queue_refresh_index',
|
||||
targetType: 'search_index',
|
||||
targetId: '0',
|
||||
metadata: {index_type: 'favorite_memes', job_id: expect.any(String), user_id: admin.userId},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/search/index-refreshes/:job_id',
|
||||
exempt: true,
|
||||
async prepare() {
|
||||
return {request: {path: '/admin/search/index-refreshes/1501314428688998182'}};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {SearchAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SearchAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('SearchAdminController', SearchAdminAuditCases);
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {expect} from 'vitest';
|
||||
|
||||
export const SystemAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/system/heap-snapshots',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/system/heap-snapshots'},
|
||||
expected: {
|
||||
action: 'create_heap_snapshot',
|
||||
targetType: 'system',
|
||||
targetId: '0',
|
||||
metadata: {size_bytes: expect.any(String)},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {SystemAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SystemAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('SystemAdminController', SystemAdminAuditCases);
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
|
||||
export const SystemDmAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/system-dms',
|
||||
async prepare({harness}) {
|
||||
const first = await createTestAccount(harness);
|
||||
const second = await createTestAccount(harness);
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/system-dms',
|
||||
body: {content: 'Scheduled maintenance', user_ids: [first.userId, second.userId]},
|
||||
},
|
||||
expected: {
|
||||
action: 'system_dm.send',
|
||||
targetType: 'system_dm',
|
||||
targetId: '0',
|
||||
metadata: {recipient_count: '2', content_length: '21'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {SystemDmAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SystemDmAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('SystemDmAdminController', SystemDmAdminAuditCases);
|
||||
@@ -0,0 +1,376 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createGroupDmChannel,
|
||||
createGuild,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {getUserActivityBuffer} from '@app/api/middleware/ServiceSingletons';
|
||||
import {HTTP_STATUS, TEST_CREDENTIALS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
|
||||
const MISSING_USER_ID = '999999999999999999';
|
||||
const LAST_ACTIVE_IP = '198.51.100.91';
|
||||
|
||||
export const UserAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/acls',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/acls'},
|
||||
expected: {
|
||||
action: 'list_admin_acls',
|
||||
targetType: 'admin_acl',
|
||||
targetId: '0',
|
||||
metadata: {acl_count: String(Object.values(AdminACLs).length)},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/@me',
|
||||
exempt: true,
|
||||
async prepare() {
|
||||
return {request: {path: '/admin/users/@me'}};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users',
|
||||
name: 'user_id',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users?user_id=${target.userId}`},
|
||||
expected: {
|
||||
action: 'search_users',
|
||||
targetType: 'user',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
selector: 'user_id',
|
||||
user_id: target.userId,
|
||||
user_id_count: '1',
|
||||
result_count: '1',
|
||||
total: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users',
|
||||
name: 'several user_id',
|
||||
async prepare({harness}) {
|
||||
const first = await createTestAccount(harness);
|
||||
const second = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users?user_id=${first.userId}&user_id=${second.userId}&user_id=${MISSING_USER_ID}`},
|
||||
expected: {
|
||||
action: 'search_users',
|
||||
targetType: 'user',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
selector: 'user_id',
|
||||
user_id_count: '3',
|
||||
result_count: '2',
|
||||
total: '2',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users',
|
||||
name: 'resolve',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users?resolve=${encodeURIComponent(target.email)}`},
|
||||
expected: {
|
||||
action: 'search_users',
|
||||
targetType: 'user',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
selector: 'resolve',
|
||||
result_count: '1',
|
||||
total: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users',
|
||||
name: 'email',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users?email=${encodeURIComponent(target.email)}&limit=10`},
|
||||
expected: {
|
||||
action: 'search_users',
|
||||
targetType: 'user',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
selector: 'email',
|
||||
result_count: '1',
|
||||
total: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users',
|
||||
name: 'last_active_ip',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
await createBuilder(harness, target.token)
|
||||
.get('/users/@me')
|
||||
.header('x-forwarded-for', LAST_ACTIVE_IP)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await getUserActivityBuffer().drainAndFlush();
|
||||
return {
|
||||
request: {path: `/admin/users?last_active_ip=${encodeURIComponent(LAST_ACTIVE_IP)}&limit=10&offset=0`},
|
||||
expected: {
|
||||
action: 'search_users',
|
||||
targetType: 'user',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
selector: 'last_active_ip',
|
||||
limit: '10',
|
||||
offset: '0',
|
||||
result_count: '1',
|
||||
total: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users',
|
||||
name: 'search',
|
||||
search: 'enabled',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/users?q=nobody-matches-this-query&limit=25'},
|
||||
expected: {
|
||||
action: 'search_users',
|
||||
targetType: 'user',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
selector: 'search',
|
||||
has_query: 'true',
|
||||
limit: '25',
|
||||
offset: '0',
|
||||
result_count: '0',
|
||||
total: '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/:user_id',
|
||||
name: 'found',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}`},
|
||||
expected: {
|
||||
action: 'get_user',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {found: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/:user_id',
|
||||
name: 'missing',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: `/admin/users/${MISSING_USER_ID}`},
|
||||
expected: {
|
||||
action: 'get_user',
|
||||
targetType: 'user',
|
||||
targetId: MISSING_USER_ID,
|
||||
metadata: {found: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/:user_id/guilds',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
await createGuild(harness, target.token, 'Audit Guild One');
|
||||
const second = await createGuild(harness, target.token, 'Audit Guild Two');
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/guilds?before=${second.id}&limit=50`},
|
||||
expected: {
|
||||
action: 'list_user_guilds',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {
|
||||
before_guild_id: second.id,
|
||||
limit: '50',
|
||||
with_counts: 'false',
|
||||
guild_count: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/:user_id/dm-channels',
|
||||
name: 'dm',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const first = await createTestAccount(harness);
|
||||
const second = await createTestAccount(harness);
|
||||
await createFriendship(harness, target, first);
|
||||
await createFriendship(harness, target, second);
|
||||
const firstDm = await createDmChannel(harness, target.token, first.userId);
|
||||
await createDmChannel(harness, target.token, second.userId);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/dm-channels?after=${firstDm.id}&limit=10`},
|
||||
expected: {
|
||||
action: 'list_user_dm_channels',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {
|
||||
type: 'dm',
|
||||
after_channel_id: firstDm.id,
|
||||
limit: '10',
|
||||
channel_count: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/:user_id/dm-channels',
|
||||
name: 'group_dm',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const first = await createTestAccount(harness);
|
||||
const second = await createTestAccount(harness);
|
||||
await createFriendship(harness, target, first);
|
||||
await createFriendship(harness, target, second);
|
||||
await createGroupDmChannel(harness, target.token, [first.userId, second.userId]);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/dm-channels?type=group_dm`},
|
||||
expected: {
|
||||
action: 'list_user_dm_channels',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {type: 'group_dm', channel_count: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/:user_id/change-log',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
for (const username of ['auditfirstname', 'auditsecondname']) {
|
||||
await createBuilder(harness, target.token)
|
||||
.patch('/users/@me')
|
||||
.body({username, password: TEST_CREDENTIALS.STRONG_PASSWORD})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/change-log?limit=50`},
|
||||
expected: {
|
||||
action: 'list_user_change_log',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {
|
||||
limit: '50',
|
||||
entry_count: '2',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/:user_id/relationships',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const friend = await createTestAccount(harness);
|
||||
const requested = await createTestAccount(harness);
|
||||
await createFriendship(harness, target, friend);
|
||||
await createBuilder(harness, target.token)
|
||||
.post(`/users/@me/relationships/${requested.userId}`)
|
||||
.body({})
|
||||
.execute();
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/relationships`},
|
||||
expected: {
|
||||
action: 'list_user_relationships',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {
|
||||
friend_count: '1',
|
||||
incoming_request_count: '0',
|
||||
outgoing_request_count: '1',
|
||||
blocked_count: '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/:user_id/sessions',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/sessions`},
|
||||
expected: {
|
||||
action: 'list_user_sessions',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {session_count: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/users/:user_id/webauthn-credentials',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/webauthn-credentials`},
|
||||
expected: {
|
||||
action: 'list_webauthn_credentials',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {credential_count: '0'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {UserAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('UserAdminController', UserAdminAuditCases);
|
||||
@@ -0,0 +1,562 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
AdminAuditCoverageCase,
|
||||
AdminAuditCoverageContext,
|
||||
} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
generateTotpCode,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createRegistrationResponse,
|
||||
createWebAuthnDevice,
|
||||
type WebAuthnCredentialMetadata,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {createFriendship} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {PremiumFlags, SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {expect} from 'vitest';
|
||||
|
||||
async function loadUser(account: TestAccount): Promise<User> {
|
||||
const user = await getUserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
if (!user) {
|
||||
throw new Error(`User ${account.userId} not found`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
function adminBuilder({harness, admin}: AdminAuditCoverageContext) {
|
||||
return createBuilder(harness, admin.token);
|
||||
}
|
||||
|
||||
async function enableTotp(context: AdminAuditCoverageContext, account: TestAccount): Promise<string> {
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(context.harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
return secret;
|
||||
}
|
||||
|
||||
export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/users/:user_id/relationships',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const friend = await createTestAccount(harness);
|
||||
await createFriendship(harness, target, friend);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/relationships?category=friend`},
|
||||
expected: {
|
||||
action: 'remove_relationships_by_category',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {category: 'friend', removed_count: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/users/:user_id/relationships/:target_user_id',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const friend = await createTestAccount(harness);
|
||||
await createFriendship(harness, target, friend);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/users/${target.userId}/relationships/${friend.userId}?category=friend`,
|
||||
expectStatus: 204,
|
||||
},
|
||||
expected: {
|
||||
action: 'remove_relationship',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {target_user_id: friend.userId, category: 'friend'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/users/:user_id/sessions',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/sessions`},
|
||||
expected: {
|
||||
action: 'terminate_sessions',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/users/:user_id/webauthn-credentials/:credential_id',
|
||||
async prepare(context) {
|
||||
const {harness} = context;
|
||||
const target = await createTestAccount(harness);
|
||||
const secret = await enableTotp(context, target);
|
||||
const device = createWebAuthnDevice();
|
||||
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, target.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (registrationOptions.rp.id) {
|
||||
device.rpId = registrationOptions.rp.id;
|
||||
}
|
||||
await createBuilder(harness, target.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: createRegistrationResponse(device, registrationOptions, 'Coverage Passkey'),
|
||||
challenge: registrationOptions.challenge,
|
||||
name: 'Coverage Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const credentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
const credentialId = credentials[0]!.id;
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/users/${target.userId}/webauthn-credentials/${credentialId}`,
|
||||
expectStatus: 204,
|
||||
},
|
||||
expected: {
|
||||
action: 'delete_webauthn_credential',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {credential_id: credentialId},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/users/:user_id/mfa',
|
||||
async prepare(context) {
|
||||
const target = await createTestAccount(context.harness);
|
||||
await enableTotp(context, target);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/mfa`, expectStatus: 204},
|
||||
expected: {
|
||||
action: 'disable_mfa',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/users/:user_id/profile-fields',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/users/${target.userId}/profile-fields`,
|
||||
body: {fields: ['bio', 'pronouns', 'global_name']},
|
||||
},
|
||||
expected: {
|
||||
action: 'clear_fields',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {fields: 'bio,pronouns,global_name'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/bot-status',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/bot-status`, body: {bot: true}},
|
||||
expected: {
|
||||
action: 'set_bot_status',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {bot: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/system-status',
|
||||
async prepare(context) {
|
||||
const target = await createTestAccount(context.harness);
|
||||
await adminBuilder(context).put(`/admin/users/${target.userId}/bot-status`).body({bot: true}).execute();
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/system-status`, body: {system: true}},
|
||||
expected: {
|
||||
action: 'set_system_status',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {system: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/users/:user_id/username',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const username = createUniqueUsername('renamed');
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/username`, body: {username}},
|
||||
expected: {
|
||||
action: 'change_username',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {
|
||||
old_username: (await loadUser(target)).username,
|
||||
new_username: username,
|
||||
discriminator: expect.any(String),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/users/:user_id/email',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const email = createUniqueEmail('changed');
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/email`, body: {email}},
|
||||
expected: {
|
||||
action: 'change_email',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {old_email: (await loadUser(target)).email!, new_email: email},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/email-verification',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness, {skipEmailVerification: true});
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/email-verification`},
|
||||
expected: {
|
||||
action: 'verify_email',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {email: (await loadUser(target)).email!},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/users/:user_id/verification-email',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness, {skipEmailVerification: true});
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/verification-email`, expectStatus: 204},
|
||||
expected: {
|
||||
action: 'resend_verification_email',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {email: (await loadUser(target)).email!},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/users/:user_id/password-reset',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/password-reset`, expectStatus: 204},
|
||||
expected: {
|
||||
action: 'send_password_reset',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {email: (await loadUser(target)).email!},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/ban',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/users/${target.userId}/ban`,
|
||||
body: {duration_hours: 24, reason: 'Coverage ban'},
|
||||
},
|
||||
expected: {
|
||||
action: 'temp_ban',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {duration_hours: '24', reason: 'Coverage ban', banned_until: expect.any(String)},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/users/:user_id/ban',
|
||||
async prepare(context) {
|
||||
const target = await createTestAccount(context.harness);
|
||||
await adminBuilder(context).put(`/admin/users/${target.userId}/ban`).body({duration_hours: 24}).execute();
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/ban`},
|
||||
expected: {
|
||||
action: 'unban',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/deletion',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/users/${target.userId}/deletion`,
|
||||
body: {reason_code: DeletionReasons.USER_REQUESTED, days_until_deletion: 30},
|
||||
},
|
||||
expected: {
|
||||
action: 'schedule_deletion',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {days: '30', reason_code: DeletionReasons.USER_REQUESTED.toString()},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/users/:user_id/deletion',
|
||||
async prepare(context) {
|
||||
const target = await createTestAccount(context.harness);
|
||||
await adminBuilder(context)
|
||||
.put(`/admin/users/${target.userId}/deletion`)
|
||||
.body({reason_code: DeletionReasons.USER_REQUESTED, days_until_deletion: 30})
|
||||
.execute();
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/deletion`},
|
||||
expected: {
|
||||
action: 'cancel_deletion',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/users/:user_id/message-deletion',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
await createBuilder(harness, target.token)
|
||||
.post('/users/@me/messages/delete')
|
||||
.body({password: target.password})
|
||||
.expect(204)
|
||||
.execute();
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/message-deletion`},
|
||||
expected: {
|
||||
action: 'cancel_bulk_message_deletion',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/acls',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const acls = [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP];
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/acls`, body: {acls}},
|
||||
expected: {
|
||||
action: 'set_acls',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {acls: acls.join(',')},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/traits',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/traits`, body: {traits: ['coverage_trait']}},
|
||||
expected: {
|
||||
action: 'set_traits',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {traits: 'coverage_trait'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/users/:user_id/flags',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const {flags} = await loadUser(target);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/users/${target.userId}/flags`,
|
||||
body: {add_flags: [UserFlags.PARTNER.toString()], remove_flags: [UserFlags.SPAMMER.toString()]},
|
||||
},
|
||||
expected: {
|
||||
action: 'update_flags',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {
|
||||
add_flags: UserFlags.PARTNER.toString(),
|
||||
remove_flags: UserFlags.SPAMMER.toString(),
|
||||
new_flags: ((flags | UserFlags.PARTNER) & ~UserFlags.SPAMMER).toString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/users/:user_id/premium-flags',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const {premiumFlags} = await loadUser(target);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/users/${target.userId}/premium-flags`,
|
||||
body: {add_flags: [PremiumFlags.BADGE_HIDDEN], remove_flags: [PremiumFlags.PURCHASE_DISABLED]},
|
||||
},
|
||||
expected: {
|
||||
action: 'update_premium_flags',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {
|
||||
add_flags: PremiumFlags.BADGE_HIDDEN.toString(),
|
||||
remove_flags: PremiumFlags.PURCHASE_DISABLED.toString(),
|
||||
new_flags: ((premiumFlags | PremiumFlags.BADGE_HIDDEN) & ~PremiumFlags.PURCHASE_DISABLED).toString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/phone-verification',
|
||||
async prepare(context) {
|
||||
const target = await createTestAccount(context.harness);
|
||||
const flags = SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE;
|
||||
await adminBuilder(context)
|
||||
.put(`/admin/users/${target.userId}/suspicious-activity-flags`)
|
||||
.body({flags})
|
||||
.execute();
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/users/${target.userId}/phone-verification`,
|
||||
body: {has_verified_phone: true},
|
||||
},
|
||||
expected: {
|
||||
action: 'update_has_verified_phone',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {
|
||||
has_verified_phone: 'true',
|
||||
suspicious_activity_flags_before: flags.toString(),
|
||||
suspicious_activity_flags_after: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL.toString(),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/users/:user_id/date-of-birth',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/date-of-birth`, body: {date_of_birth: '1995-06-15'}},
|
||||
expected: {
|
||||
action: 'change_dob',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {old_dob: (await loadUser(target)).dateOfBirth!, new_dob: '1995-06-15'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/suspicious-activity-flags',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const flags = SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE;
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/suspicious-activity-flags`, body: {flags}},
|
||||
expected: {
|
||||
action: 'update_suspicious_activity_flags',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {flags: flags.toString()},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/suspicious-activity-disablement',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
const flags = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE;
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/suspicious-activity-disablement`, body: {flags}},
|
||||
expected: {
|
||||
action: 'disable_suspicious_activity',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {flags: flags.toString()},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {UserWriteAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserWriteAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('UserAdminController writes', UserWriteAdminAuditCases);
|
||||
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
|
||||
const REGION_ID = 'coverage-region';
|
||||
const REGION_NAME = 'Coverage Region';
|
||||
const SERVER_ID = 'coverage-server';
|
||||
const SERVER_ENDPOINT = 'wss://voice-coverage.example.com/livekit';
|
||||
|
||||
async function createRegion(harness: ApiTestHarness, admin: TestAccount): Promise<void> {
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/voice/regions')
|
||||
.body({id: REGION_ID, name: REGION_NAME, emoji: ':earth_africa:', latitude: 1, longitude: 2})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function createRegionWithServer(harness: ApiTestHarness, admin: TestAccount): Promise<void> {
|
||||
await createRegion(harness, admin);
|
||||
await createBuilder(harness, admin.token)
|
||||
.post(`/admin/voice/regions/${REGION_ID}/servers`)
|
||||
.body({
|
||||
server_id: SERVER_ID,
|
||||
endpoint: SERVER_ENDPOINT,
|
||||
api_key: 'coverage-api-key',
|
||||
api_secret: 'coverage-api-secret',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export const VoiceAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/voice/regions',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegionWithServer(harness, admin);
|
||||
return {
|
||||
request: {path: '/admin/voice/regions?include_servers=true'},
|
||||
expected: {
|
||||
action: 'list_voice_regions',
|
||||
targetType: 'voice_region',
|
||||
targetId: '0',
|
||||
metadata: {include_servers: 'true', result_count: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/voice/regions',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/voice/regions',
|
||||
body: {id: REGION_ID, name: REGION_NAME, emoji: ':earth_africa:', latitude: 1, longitude: 2},
|
||||
},
|
||||
expected: {
|
||||
action: 'create_voice_region',
|
||||
targetType: 'voice_region',
|
||||
targetId: '0',
|
||||
metadata: {region_id: REGION_ID, name: REGION_NAME},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/voice/regions/:region_id',
|
||||
name: 'found',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegionWithServer(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/voice/regions/${REGION_ID}`},
|
||||
expected: {
|
||||
action: 'get_voice_region',
|
||||
targetType: 'voice_region',
|
||||
targetId: '0',
|
||||
metadata: {region_id: REGION_ID, include_servers: 'true', found: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/voice/regions/:region_id',
|
||||
name: 'missing',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/voice/regions/missing-region?include_servers=false'},
|
||||
expected: {
|
||||
action: 'get_voice_region',
|
||||
targetType: 'voice_region',
|
||||
targetId: '0',
|
||||
metadata: {region_id: 'missing-region', include_servers: 'false', found: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/voice/regions/:region_id',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegion(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/voice/regions/${REGION_ID}`, body: {name: 'Renamed Region'}},
|
||||
expected: {
|
||||
action: 'update_voice_region',
|
||||
targetType: 'voice_region',
|
||||
targetId: '0',
|
||||
metadata: {region_id: REGION_ID},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/voice/regions/:region_id',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegion(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/voice/regions/${REGION_ID}`},
|
||||
expected: {
|
||||
action: 'delete_voice_region',
|
||||
targetType: 'voice_region',
|
||||
targetId: '0',
|
||||
metadata: {region_id: REGION_ID, name: REGION_NAME},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/voice/regions/:region_id/servers',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegionWithServer(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/voice/regions/${REGION_ID}/servers`},
|
||||
expected: {
|
||||
action: 'list_voice_servers',
|
||||
targetType: 'voice_server',
|
||||
targetId: '0',
|
||||
metadata: {region_id: REGION_ID, result_count: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/voice/regions/:region_id/servers',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegion(harness, admin);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/voice/regions/${REGION_ID}/servers`,
|
||||
body: {
|
||||
server_id: SERVER_ID,
|
||||
endpoint: SERVER_ENDPOINT,
|
||||
api_key: 'coverage-api-key',
|
||||
api_secret: 'coverage-api-secret',
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
action: 'create_voice_server',
|
||||
targetType: 'voice_server',
|
||||
targetId: '0',
|
||||
metadata: {region_id: REGION_ID, server_id: SERVER_ID, endpoint: SERVER_ENDPOINT},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/voice/regions/:region_id/servers/:server_id',
|
||||
name: 'found',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegionWithServer(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/voice/regions/${REGION_ID}/servers/${SERVER_ID}`},
|
||||
expected: {
|
||||
action: 'get_voice_server',
|
||||
targetType: 'voice_server',
|
||||
targetId: '0',
|
||||
metadata: {region_id: REGION_ID, server_id: SERVER_ID, found: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/voice/regions/:region_id/servers/:server_id',
|
||||
name: 'missing',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegion(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/voice/regions/${REGION_ID}/servers/missing-server`},
|
||||
expected: {
|
||||
action: 'get_voice_server',
|
||||
targetType: 'voice_server',
|
||||
targetId: '0',
|
||||
metadata: {region_id: REGION_ID, server_id: 'missing-server', found: 'false'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/voice/regions/:region_id/servers/:server_id',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegionWithServer(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/voice/regions/${REGION_ID}/servers/${SERVER_ID}`, body: {is_active: false}},
|
||||
expected: {
|
||||
action: 'update_voice_server',
|
||||
targetType: 'voice_server',
|
||||
targetId: '0',
|
||||
metadata: {region_id: REGION_ID, server_id: SERVER_ID},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
route: '/admin/voice/regions/:region_id/servers/:server_id',
|
||||
async prepare({harness, admin}) {
|
||||
await createRegionWithServer(harness, admin);
|
||||
return {
|
||||
request: {path: `/admin/voice/regions/${REGION_ID}/servers/${SERVER_ID}`},
|
||||
expected: {
|
||||
action: 'delete_voice_server',
|
||||
targetType: 'voice_server',
|
||||
targetId: '0',
|
||||
metadata: {region_id: REGION_ID, server_id: SERVER_ID, endpoint: SERVER_ENDPOINT},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {VoiceAdminAuditCases} from '@app/api/admin/tests/audit_coverage/VoiceAdminAuditCases';
|
||||
|
||||
describeAdminAuditCoverage('VoiceAdminController', VoiceAdminAuditCases);
|
||||
@@ -223,7 +223,7 @@ export class MessageInteractionService {
|
||||
emoji: string;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
await this.reactionService.removeAllReactionsForEmoji({authChannel, messageId, emoji, actorId: userId});
|
||||
await this.reactionService.removeAllReactionsForEmoji({authChannel, messageId, emoji});
|
||||
}
|
||||
|
||||
async removeAllReactions({
|
||||
@@ -236,7 +236,7 @@ export class MessageInteractionService {
|
||||
messageId: MessageID;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
await this.reactionService.removeAllReactions({authChannel, messageId, actorId: userId});
|
||||
await this.reactionService.removeAllReactions({authChannel, messageId});
|
||||
}
|
||||
|
||||
async getMessageReactions({
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import type {MessageReaction} from '@app/api/models/MessageReaction';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
@@ -274,7 +273,7 @@ export class MessageReactionService extends MessageInteractionBase {
|
||||
if (!message) return;
|
||||
const isRemovingOwnReaction = targetId === actorId;
|
||||
if (!isRemovingOwnReaction) {
|
||||
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
|
||||
await this.assertCanModerateMessageReactions({channel, hasPermission});
|
||||
}
|
||||
const emojiId = parsedEmoji.id ? createEmojiID(BigInt(parsedEmoji.id)) : undefined;
|
||||
await this.channelRepository.messageInteractions.removeReaction(
|
||||
@@ -297,12 +296,10 @@ export class MessageReactionService extends MessageInteractionBase {
|
||||
authChannel,
|
||||
messageId,
|
||||
emoji,
|
||||
actorId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
emoji: string;
|
||||
actorId: UserID;
|
||||
}): Promise<void> {
|
||||
const channel = authChannel.channel;
|
||||
const {guild, hasPermission} = authChannel;
|
||||
@@ -314,7 +311,7 @@ export class MessageReactionService extends MessageInteractionBase {
|
||||
const parsedEmoji = this.parseEmojiWithoutValidation(emoji);
|
||||
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!message) return;
|
||||
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
|
||||
await this.assertCanModerateMessageReactions({channel, hasPermission});
|
||||
const emojiId = parsedEmoji.id ? createEmojiID(BigInt(parsedEmoji.id)) : undefined;
|
||||
await this.channelRepository.messageInteractions.removeAllReactionsForEmoji(
|
||||
channel.id,
|
||||
@@ -332,11 +329,9 @@ export class MessageReactionService extends MessageInteractionBase {
|
||||
async removeAllReactions({
|
||||
authChannel,
|
||||
messageId,
|
||||
actorId,
|
||||
}: {
|
||||
authChannel: AuthenticatedChannel;
|
||||
messageId: MessageID;
|
||||
actorId: UserID;
|
||||
}): Promise<void> {
|
||||
const channel = authChannel.channel;
|
||||
const {guild, hasPermission} = authChannel;
|
||||
@@ -347,7 +342,7 @@ export class MessageReactionService extends MessageInteractionBase {
|
||||
}
|
||||
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
||||
if (!message) return;
|
||||
await this.assertCanModerateMessageReactions({channel, message, actorId, hasPermission});
|
||||
await this.assertCanModerateMessageReactions({channel, hasPermission});
|
||||
await this.channelRepository.messageInteractions.removeAllReactions(channel.id, messageId);
|
||||
await this.dispatchMessageReactionRemoveAll({channel, messageId});
|
||||
}
|
||||
@@ -365,18 +360,11 @@ export class MessageReactionService extends MessageInteractionBase {
|
||||
|
||||
private async assertCanModerateMessageReactions({
|
||||
channel,
|
||||
message,
|
||||
actorId,
|
||||
hasPermission,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
actorId: UserID;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
}): Promise<void> {
|
||||
if (message.authorId === actorId) {
|
||||
return;
|
||||
}
|
||||
if (!channel.guildId) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createPermissionOverwrite,
|
||||
sendChannelMessage,
|
||||
setupTestGuildWithMembers,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const EMOJI = encodeURIComponent('👍');
|
||||
|
||||
interface ReactionUsersPage {
|
||||
items: Array<{id: string}>;
|
||||
}
|
||||
|
||||
describe('Reaction removal permissions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function react(account: TestAccount, channelId: string, messageId: string): Promise<void> {
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}/@me`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function listReactorIds(account: TestAccount, channelId: string, messageId: string): Promise<Array<string>> {
|
||||
const page = await createBuilder<ReactionUsersPage>(harness, account.token)
|
||||
.get(`/channels/${channelId}/messages/${messageId}/reactions/${EMOJI}/users`)
|
||||
.execute();
|
||||
return page.items.map((user) => user.id);
|
||||
}
|
||||
|
||||
async function expectRemovalsRejected(
|
||||
account: TestAccount,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
targetId: string,
|
||||
): Promise<void> {
|
||||
const base = `/channels/${channelId}/messages/${messageId}/reactions`;
|
||||
for (const route of [`${base}/${EMOJI}/${targetId}`, `${base}/${EMOJI}`, base]) {
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(route)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
it('requires MANAGE_MESSAGES for the author to remove reactions from their own guild message', async () => {
|
||||
const {members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [author, reactor] = members as [TestAccount, TestAccount];
|
||||
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'react to me');
|
||||
await react(reactor, systemChannel.id, message.id);
|
||||
|
||||
await expectRemovalsRejected(author, systemChannel.id, message.id, reactor.userId);
|
||||
|
||||
expect(await listReactorIds(author, systemChannel.id, message.id)).toEqual([reactor.userId]);
|
||||
});
|
||||
|
||||
it('refuses the author removing reactions from their own direct message', async () => {
|
||||
const author = await createTestAccount(harness);
|
||||
const reactor = await createTestAccount(harness);
|
||||
await createFriendship(harness, author, reactor);
|
||||
const dm = await createDmChannel(harness, author.token, reactor.userId);
|
||||
const message = await sendChannelMessage(harness, author.token, dm.id, 'react to me');
|
||||
await react(reactor, dm.id, message.id);
|
||||
|
||||
await expectRemovalsRejected(author, dm.id, message.id, reactor.userId);
|
||||
|
||||
expect(await listReactorIds(author, dm.id, message.id)).toEqual([reactor.userId]);
|
||||
});
|
||||
|
||||
it('lets a member with MANAGE_MESSAGES remove reactions from another member message', async () => {
|
||||
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 3);
|
||||
const [author, reactor, moderator] = members as [TestAccount, TestAccount, TestAccount];
|
||||
await createPermissionOverwrite(harness, owner.token, systemChannel.id, moderator.userId, {
|
||||
type: 1,
|
||||
allow: Permissions.MANAGE_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'react to me');
|
||||
const base = `/channels/${systemChannel.id}/messages/${message.id}/reactions`;
|
||||
|
||||
await react(reactor, systemChannel.id, message.id);
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`${base}/${EMOJI}/${reactor.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
expect(await listReactorIds(moderator, systemChannel.id, message.id)).toEqual([]);
|
||||
|
||||
await react(reactor, systemChannel.id, message.id);
|
||||
await createBuilder(harness, moderator.token).delete(`${base}/${EMOJI}`).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
expect(await listReactorIds(moderator, systemChannel.id, message.id)).toEqual([]);
|
||||
|
||||
await react(reactor, systemChannel.id, message.id);
|
||||
await createBuilder(harness, moderator.token).delete(base).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
expect(await listReactorIds(moderator, systemChannel.id, message.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets a member without MANAGE_MESSAGES remove their own reaction by user ID', async () => {
|
||||
const {members, systemChannel} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [author, reactor] = members as [TestAccount, TestAccount];
|
||||
const message = await sendChannelMessage(harness, author.token, systemChannel.id, 'react to me');
|
||||
await react(reactor, systemChannel.id, message.id);
|
||||
|
||||
await createBuilder(harness, reactor.token)
|
||||
.delete(`/channels/${systemChannel.id}/messages/${message.id}/reactions/${EMOJI}/${reactor.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
expect(await listReactorIds(author, systemChannel.id, message.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -324,6 +324,8 @@ export interface APIConfig {
|
||||
wordmarkUrl?: string;
|
||||
faviconUrl?: string;
|
||||
themeColor?: string;
|
||||
statusPageUrl?: string;
|
||||
statusPageIncidentHistoryUrl?: string;
|
||||
};
|
||||
setup: {
|
||||
configured: boolean;
|
||||
|
||||
@@ -9,14 +9,7 @@ import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {entityTagMatches} from '@app/api/utils/EntityTag';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {resolveBlockedMessageGroupsAssignment} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {resolveExpressionInfoCardAssignment} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
|
||||
import {resolveGuildActivityLogPresentationAssignment} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {resolveGuildHeaderCollapseAssignment} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {resolveMessageHoverTrackingAssignment} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
import {resolveMessageKeyboardFocusAssignment} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
import {resolveTypingIndicatorReworkAssignment} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas';
|
||||
|
||||
export function ExperimentController(app: HonoApp) {
|
||||
app.get(
|
||||
@@ -35,43 +28,15 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [
|
||||
delivery,
|
||||
voiceConfig,
|
||||
messageHoverTrackingConfig,
|
||||
messageKeyboardFocusConfig,
|
||||
blockedMessageGroupsConfig,
|
||||
guildActivityLogPresentationConfig,
|
||||
expressionInfoCardConfig,
|
||||
guildHeaderCollapseConfig,
|
||||
typingIndicatorReworkConfig,
|
||||
] = await Promise.all([
|
||||
const [delivery, voiceConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getMessageHoverTrackingConfig(),
|
||||
instanceConfigRepository.getMessageKeyboardFocusConfig(),
|
||||
instanceConfigRepository.getBlockedMessageGroupsConfig(),
|
||||
instanceConfigRepository.getGuildActivityLogPresentationConfig(),
|
||||
instanceConfigRepository.getExpressionInfoCardConfig(),
|
||||
instanceConfigRepository.getGuildHeaderCollapseConfig(),
|
||||
instanceConfigRepository.getTypingIndicatorReworkConfig(),
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
message_hover_tracking: resolveMessageHoverTrackingAssignment(messageHoverTrackingConfig, userId),
|
||||
message_keyboard_focus: resolveMessageKeyboardFocusAssignment(messageKeyboardFocusConfig, userId),
|
||||
blocked_message_groups: resolveBlockedMessageGroupsAssignment(blockedMessageGroupsConfig, userId),
|
||||
guild_activity_log_presentation: resolveGuildActivityLogPresentationAssignment(
|
||||
guildActivityLogPresentationConfig,
|
||||
userId,
|
||||
),
|
||||
expression_info_card: resolveExpressionInfoCardAssignment(expressionInfoCardConfig, userId),
|
||||
guild_header_collapse: resolveGuildHeaderCollapseAssignment(guildHeaderCollapseConfig, userId),
|
||||
typing_indicator_rework: resolveTypingIndicatorReworkAssignment(typingIndicatorReworkConfig, userId),
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, ctx.get('user').id.toString()),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -10,48 +10,13 @@ import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
|
||||
INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
type ExperimentAssignmentsResponse,
|
||||
type ExperimentDeliveryConfigResponse,
|
||||
readBlockedMessageGroupsAssignment,
|
||||
readExpressionInfoCardAssignment,
|
||||
readGuildActivityLogPresentationAssignment,
|
||||
readGuildHeaderCollapseAssignment,
|
||||
readMessageHoverTrackingAssignment,
|
||||
readMessageKeyboardFocusAssignment,
|
||||
readTypingIndicatorReworkAssignment,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {
|
||||
DEFAULT_EXPRESSION_INFO_CARD_CONFIG,
|
||||
INERT_EXPRESSION_INFO_CARD_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
|
||||
import {
|
||||
DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
|
||||
INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {
|
||||
DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
|
||||
import {
|
||||
DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
import {
|
||||
DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG,
|
||||
INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
import {
|
||||
DEFAULT_TYPING_INDICATOR_REWORK_CONFIG,
|
||||
INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const NOT_MODIFIED = 304;
|
||||
@@ -84,16 +49,7 @@ describe('GET /experiments', () => {
|
||||
expect(body).toEqual({
|
||||
poll_interval_seconds: DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
message_hover_tracking: INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
|
||||
message_keyboard_focus: INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT,
|
||||
blocked_message_groups: INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT,
|
||||
guild_activity_log_presentation: INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT,
|
||||
expression_info_card: INERT_EXPRESSION_INFO_CARD_ASSIGNMENT,
|
||||
guild_header_collapse: INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT,
|
||||
typing_indicator_rework: INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT,
|
||||
},
|
||||
assignments: {voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,428 +80,6 @@ describe('GET /experiments', () => {
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('populates the message hover tracking key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'message_hover_tracking')).toBe(true);
|
||||
expect(readMessageHoverTrackingAssignment(body)).toEqual(INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for message hover tracking', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageHoverTrackingConfig({
|
||||
...DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readMessageHoverTrackingAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an account outside a zero-width message hover tracking rollout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageHoverTrackingConfig({
|
||||
...DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readMessageHoverTrackingAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the message keyboard focus key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'message_keyboard_focus')).toBe(true);
|
||||
expect(readMessageKeyboardFocusAssignment(body)).toEqual(INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for message keyboard focus', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageKeyboardFocusConfig({
|
||||
...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readMessageKeyboardFocusAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an account outside a zero-width message keyboard focus rollout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageKeyboardFocusConfig({
|
||||
...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readMessageKeyboardFocusAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the blocked message groups key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'blocked_message_groups')).toBe(true);
|
||||
expect(readBlockedMessageGroupsAssignment(body)).toEqual(INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for blocked message groups', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setBlockedMessageGroupsConfig({
|
||||
...DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readBlockedMessageGroupsAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an account outside a zero-width blocked message groups rollout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setBlockedMessageGroupsConfig({
|
||||
...DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readBlockedMessageGroupsAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the guild activity log presentation key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'guild_activity_log_presentation')).toBe(true);
|
||||
expect(readGuildActivityLogPresentationAssignment(body)).toEqual(INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for guild activity log presentation', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setGuildActivityLogPresentationConfig({
|
||||
...DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 6,
|
||||
included_user_ids: [targeted.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(readGuildActivityLogPresentationAssignment(targetedBody)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 6,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(readGuildActivityLogPresentationAssignment(untargetedBody)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 6,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('bumps the guild activity log presentation config version on every admin update', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const updated = await createBuilder<{
|
||||
guild_activity_log_presentation: {config_version: number; enabled: boolean; rollout_basis_points: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({guild_activity_log_presentation: {enabled: true, rollout_basis_points: 10000}})
|
||||
.execute();
|
||||
expect(updated.guild_activity_log_presentation).toMatchObject({
|
||||
config_version: 1,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(readGuildActivityLogPresentationAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
user_targeted: true,
|
||||
source: 'canary',
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the expression info card key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'expression_info_card')).toBe(true);
|
||||
expect(readExpressionInfoCardAssignment(body)).toEqual(INERT_EXPRESSION_INFO_CARD_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for the expression info card', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExpressionInfoCardConfig({
|
||||
...DEFAULT_EXPRESSION_INFO_CARD_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readExpressionInfoCardAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an account outside a zero-width expression info card rollout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExpressionInfoCardConfig({
|
||||
...DEFAULT_EXPRESSION_INFO_CARD_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readExpressionInfoCardAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the guild header collapse key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'guild_header_collapse')).toBe(true);
|
||||
expect(readGuildHeaderCollapseAssignment(body)).toEqual(INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for guild header collapse', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setGuildHeaderCollapseConfig({
|
||||
...DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readGuildHeaderCollapseAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an account outside a zero-width guild header collapse rollout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setGuildHeaderCollapseConfig({
|
||||
...DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readGuildHeaderCollapseAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the typing indicator rework key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'typing_indicator_rework')).toBe(true);
|
||||
expect(readTypingIndicatorReworkAssignment(body)).toEqual(INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for typing indicator rework', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setTypingIndicatorReworkConfig({
|
||||
...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 6,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readTypingIndicatorReworkAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 6,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an account outside a zero-width typing indicator rework rollout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setTypingIndicatorReworkConfig({
|
||||
...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readTypingIndicatorReworkAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the typing indicator rework config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
const staleEtag = first.response.headers.get('etag') as string;
|
||||
|
||||
await getInstanceConfigRepository().setTypingIndicatorReworkConfig({
|
||||
...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.header('If-None-Match', staleEtag)
|
||||
.executeWithResponse();
|
||||
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
|
||||
expect(refreshed.json?.assignments.typing_indicator_rework).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
user_targeted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves all eight experiments independently', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageHoverTrackingConfig({
|
||||
...DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
await getInstanceConfigRepository().setMessageKeyboardFocusConfig({
|
||||
...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
await getInstanceConfigRepository().setBlockedMessageGroupsConfig({
|
||||
...DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
await getInstanceConfigRepository().setGuildActivityLogPresentationConfig({
|
||||
...DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
await getInstanceConfigRepository().setExpressionInfoCardConfig({
|
||||
...DEFAULT_EXPRESSION_INFO_CARD_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
await getInstanceConfigRepository().setGuildHeaderCollapseConfig({
|
||||
...DEFAULT_GUILD_HEADER_COLLAPSE_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
await getInstanceConfigRepository().setTypingIndicatorReworkConfig({
|
||||
...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readMessageHoverTrackingAssignment(body).user_targeted).toBe(true);
|
||||
expect(readMessageKeyboardFocusAssignment(body).user_targeted).toBe(true);
|
||||
expect(readBlockedMessageGroupsAssignment(body).user_targeted).toBe(true);
|
||||
expect(readGuildActivityLogPresentationAssignment(body).user_targeted).toBe(true);
|
||||
expect(readExpressionInfoCardAssignment(body).user_targeted).toBe(true);
|
||||
expect(readGuildHeaderCollapseAssignment(body).user_targeted).toBe(true);
|
||||
expect(readTypingIndicatorReworkAssignment(body).user_targeted).toBe(true);
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
|
||||
@@ -5,18 +5,13 @@ import {createHttpCachePurgeAdapter} from '@app/api/infrastructure/HttpCachePurg
|
||||
import {createNoneCachePurgeAdapter} from '@app/api/infrastructure/NoneCachePurgeAdapter';
|
||||
import type {CachePurgeAdapterName} from '@fluxer/config/src/MasterConfig';
|
||||
|
||||
export interface CachePurgeBatch {
|
||||
readonly exact: ReadonlyArray<string>;
|
||||
readonly prefix: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export type CachePurgeOutcome =
|
||||
| {readonly kind: 'purged'}
|
||||
| {readonly kind: 'invalid_entries'; readonly status: number}
|
||||
| {readonly kind: 'rejected'; readonly status: number}
|
||||
| {readonly kind: 'failed'; readonly status: number | null; readonly error: unknown};
|
||||
|
||||
export interface CachePurgeAdapter {
|
||||
purge(batch: CachePurgeBatch): Promise<CachePurgeOutcome>;
|
||||
purge(prefixes: ReadonlyArray<string>): Promise<CachePurgeOutcome>;
|
||||
}
|
||||
|
||||
const CACHE_PURGE_ADAPTERS = {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {canonicalizePurgeUrl} from '@app/api/infrastructure/CachePurgePaths';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const MEDIA = 'https://media.test';
|
||||
|
||||
describe('canonicalizePurgeUrl', () => {
|
||||
let previousMedia: string;
|
||||
|
||||
beforeEach(() => {
|
||||
previousMedia = Config.endpoints.media;
|
||||
Config.endpoints.media = MEDIA;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.endpoints.media = previousMedia;
|
||||
});
|
||||
|
||||
it('qualifies every prefix with the media host and no scheme', () => {
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/attachments/1/2/photo.png`)).toEqual(['media.test/attachments/1/2/photo']);
|
||||
});
|
||||
|
||||
it('drops the file extension so every served format is covered', () => {
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/emojis/1531309058777157632.webp`)).toEqual([
|
||||
'media.test/emojis/1531309058777157632',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pairs an asset hash with its animated spelling in both directions', () => {
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/avatars/1/a_b35cc3d3`)).toEqual([
|
||||
'media.test/avatars/1/a_b35cc3d3',
|
||||
'media.test/avatars/1/b35cc3d3',
|
||||
]);
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/icons/1/808b11fa.webp`)).toEqual([
|
||||
'media.test/icons/1/808b11fa',
|
||||
'media.test/icons/1/a_808b11fa',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a non-hash stem alone', () => {
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/attachments/1/2/holiday.jpg`)).toEqual([
|
||||
'media.test/attachments/1/2/holiday',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the query and fragment and decodes the path', () => {
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/attachments/1/2/ação.png?size=128#x`)).toEqual([
|
||||
'media.test/attachments/1/2/ação',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a base path when the media endpoint carries one', () => {
|
||||
Config.endpoints.media = `${MEDIA}/media`;
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/media/avatars/1/b35cc3d3`)).toEqual([
|
||||
'media.test/media/avatars/1/b35cc3d3',
|
||||
'media.test/media/avatars/1/a_b35cc3d3',
|
||||
]);
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/avatars/1/b35cc3d3`)).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses anything that is not a media CDN object', () => {
|
||||
expect(canonicalizePurgeUrl('https://elsewhere.test/avatars/1/b35cc3d3')).toEqual([]);
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/avatars`)).toEqual([]);
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/`)).toEqual([]);
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/emojis/.webp`)).toEqual([]);
|
||||
expect(canonicalizePurgeUrl('not-a-url')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
|
||||
const EXTENSION_PATTERN = /\.[A-Za-z0-9]{1,8}$/u;
|
||||
const ASSET_HASH_PATTERN = /^[0-9a-f]{8}$/u;
|
||||
const TRAILING_SLASHES_PATTERN = /\/+$/u;
|
||||
const ANIMATED_PREFIX = 'a_';
|
||||
const MIN_PATH_SEGMENTS = 2;
|
||||
|
||||
function mediaBase(): URL | null {
|
||||
return URL.parse(Config.endpoints.media);
|
||||
}
|
||||
|
||||
function decodePathname(pathname: string): string {
|
||||
try {
|
||||
return decodeURIComponent(pathname);
|
||||
} catch {
|
||||
return pathname;
|
||||
}
|
||||
}
|
||||
|
||||
function animationVariants(stem: string): Array<string> {
|
||||
if (stem.startsWith(ANIMATED_PREFIX)) {
|
||||
const bare = stem.slice(ANIMATED_PREFIX.length);
|
||||
return ASSET_HASH_PATTERN.test(bare) ? [stem, bare] : [stem];
|
||||
}
|
||||
return ASSET_HASH_PATTERN.test(stem) ? [stem, `${ANIMATED_PREFIX}${stem}`] : [stem];
|
||||
}
|
||||
|
||||
export function canonicalizePurgeUrl(url: string): Array<string> {
|
||||
const base = mediaBase();
|
||||
const parsed = URL.parse(url);
|
||||
if (base === null || parsed === null || parsed.origin !== base.origin) {
|
||||
return [];
|
||||
}
|
||||
const basePath = base.pathname.replace(TRAILING_SLASHES_PATTERN, '');
|
||||
if (!parsed.pathname.startsWith(`${basePath}/`)) {
|
||||
return [];
|
||||
}
|
||||
const segments = decodePathname(parsed.pathname.slice(basePath.length))
|
||||
.split('/')
|
||||
.filter((segment) => segment !== '');
|
||||
if (segments.length < MIN_PATH_SEGMENTS) {
|
||||
return [];
|
||||
}
|
||||
const stem = segments[segments.length - 1]!.replace(EXTENSION_PATTERN, '');
|
||||
if (stem === '') {
|
||||
return [];
|
||||
}
|
||||
const directory = segments.slice(0, -1).join('/');
|
||||
return animationVariants(stem).map((variant) => `${base.host}${basePath}/${directory}/${variant}`);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user