Compare commits

..
1411 changed files with 1121 additions and 120455 deletions
-7
View File
@@ -28,7 +28,6 @@ services:
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_ADMIN_OAUTH_REDIRECT_URI: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin/oauth2_callback"
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: "http://localhost,http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
volumes:
- ..:/workspaces/fluxer:cached
- type: volume
@@ -166,11 +165,6 @@ services:
target: /workspaces/fluxer/fluxer_api/pkgs/http_client/node_modules
volume:
nocopy: true
- type: volume
source: fluxer-api-initialization-node-modules
target: /workspaces/fluxer/fluxer_api/pkgs/initialization/node_modules
volume:
nocopy: true
- type: volume
source: fluxer-api-kv-client-node-modules
target: /workspaces/fluxer/fluxer_api/pkgs/kv_client/node_modules
@@ -371,7 +365,6 @@ volumes:
fluxer-api-email-node-modules:
fluxer-api-geoip-node-modules:
fluxer-api-http-client-node-modules:
fluxer-api-initialization-node-modules:
fluxer-api-kv-client-node-modules:
fluxer-api-locale-node-modules:
fluxer-api-media-proxy-utils-node-modules:
-1
View File
@@ -740,7 +740,6 @@ jobs:
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+5 -10
View File
@@ -15,7 +15,6 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
GHCR_REGISTRY: ghcr.io/${{ github.repository_owner }}
CARGO_PROFILE_DEV_DEBUG: none
CARGO_PROFILE_TEST_DEBUG: none
CARGO_INCREMENTAL: '0'
@@ -43,7 +42,7 @@ jobs:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
- name: Build ci helper
if: steps.ci-helper.outputs.cache-hit != 'true'
@@ -56,7 +55,7 @@ jobs:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
- name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
@@ -98,7 +97,7 @@ jobs:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
- name: Build ci helper
if: steps.ci-helper.outputs.cache-hit != 'true'
@@ -127,7 +126,6 @@ jobs:
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
@@ -150,7 +148,6 @@ jobs:
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
@@ -309,7 +306,7 @@ jobs:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
- name: Build ci helper
if: steps.ci-helper.outputs.cache-hit != 'true'
@@ -390,7 +387,7 @@ jobs:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
- name: Build ci helper
if: steps.ci-helper.outputs.cache-hit != 'true'
@@ -419,7 +416,6 @@ jobs:
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
@@ -442,7 +438,6 @@ jobs:
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
-2
View File
@@ -36,8 +36,6 @@
/fluxer_app/src/features/ui/constants/AvatarStatusGeometry.ts
/fluxer_gateway/priv/
/fluxer_desktop/native/rust/fuzz/artifacts/
/fluxer_desktop/native/rust/fuzz/corpus/
/packages/markdown_parser/rust/fuzz/artifacts/
/packages/markdown_parser/rust/fuzz/corpus/
Generated
-27
View File
@@ -1737,7 +1737,6 @@ dependencies = [
"anyhow",
"axum",
"clap",
"fluxer_common",
"hyper",
"hyper-util",
"libc",
@@ -1781,7 +1780,6 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"tempfile",
]
[[package]]
@@ -2017,24 +2015,17 @@ name = "fluxer_common"
version = "0.1.0"
dependencies = [
"anyhow",
"aws-credential-types",
"aws-sigv4",
"axum",
"base64 0.23.1",
"hex",
"hmac 0.13.0",
"maxminddb",
"moka",
"reqwest",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror",
"time",
"tracing",
"tracing-subscriber",
"url",
"urlencoding",
]
[[package]]
@@ -2637,12 +2628,6 @@ version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipnetwork"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -2891,18 +2876,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "maxminddb"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b43d2458d977b610b04a3858e6504f06133663d303a1a0606601b530bfc9bc6"
dependencies = [
"ipnetwork",
"memchr",
"serde",
"thiserror",
]
[[package]]
name = "md-5"
version = "0.11.0"
-1
View File
@@ -163,7 +163,6 @@
"**",
"!**/.git",
"!**/app.css",
"!fluxer_admin/public/static/app.css",
"!**/build",
"fluxer_app/scripts/build",
"!**/dist",
-2
View File
@@ -120,8 +120,6 @@ FLUXER_TEST_MODE_ENABLED=false
PUBLIC_BUILD_VERSION=dev
PUBLIC_RELEASE_CHANNEL=canary
PUBLIC_BOOTSTRAP_API_ENDPOINT=/api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=Zmx1eGVyLWRldi1hdHRhY2htZW50LXVybC1zZWNyZXQ=
AWS_EC2_METADATA_DISABLED=true
-3
View File
@@ -56,7 +56,6 @@ workloads:
name: fluxer-admin
replicas: 1
env:
FLUXER_ENV: production
FLUXER_API_ENDPOINT: https://api.example.com
FLUXER_ADMIN_ENDPOINT: https://admin.example.com
FLUXER_MEDIA_ENDPOINT: https://media.example.com
@@ -73,8 +72,6 @@ workloads:
replicas: 1
env:
RELEASE_CHANNEL: stable
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: https://web.example.com/api
resources:
requests:
cpu: 50m
+1 -3
View File
@@ -377,7 +377,6 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_INVITE_ENDPOINT=
#FLUXER_GIFT_ENDPOINT=
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
# This follows FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
@@ -631,8 +630,7 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES=1048576
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES=8589934592
# app-proxy discovery refresh, index upstream and manifest scope.
#DISCOVERY_REFRESH_INTERVAL_MS=60000
# app-proxy index upstream and manifest scope.
#FLUXER_APP_PROXY_INDEX_UPSTREAM_URL=
#FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS=
#FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS=
-15
View File
@@ -656,25 +656,10 @@ services:
environment:
RUST_LOG: ${RUST_LOG:-}
FLUXER_APP_PROXY_PORT: "8080"
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-}
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-}
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api}
FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-}
FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS: ${FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS:-}
FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS: ${FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS:-}
+2 -12
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::{snowflake, types as generated_types};
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{BanAvatarResult, BanCheckResult, BlocklistEntryPage, BulkBanResult};
use super::types::{BanCheckResult, BlocklistEntryPage, BulkBanResult};
impl AdminApiClient {
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
@@ -249,16 +249,6 @@ impl AdminApiClient {
.await
}
pub async fn ban_user_avatar(&self, user_id: &str) -> ApiResult<BanAvatarResult> {
let body = generated_types::BanUserAvatarRequest::default();
let response = self
.generated()
.ban_admin_user_avatar(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn ban_profile_substring(
&self,
scope: &str,
-16
View File
@@ -251,22 +251,6 @@ impl AdminApiClient {
Self::parse_response(response).await
}
pub async fn put_void_with_reason(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let response = self
.send_json(Method::PUT, path, body, audit_log_reason)
.await?;
Self::parse_void_response(response).await
}
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
self.delete_void_with_reason(path, body, None).await
}
pub async fn delete_void_with_reason(
&self,
path: &str,
-5
View File
@@ -284,11 +284,6 @@ pub struct BulkBanResult {
pub job_id: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BanAvatarResult {
pub hash_short: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SuccessResponse {
pub success: bool,
-5
View File
@@ -78,8 +78,3 @@ pub struct CreateVoiceServerResponse {
pub struct UpdateVoiceServerResponse {
pub server: VoiceServer,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GetVoiceServerResponse {
pub server: Option<VoiceServer>,
}
-10
View File
@@ -41,16 +41,6 @@ impl AdminApiClient {
})
}
pub async fn lookup_user(&self, query: &str) -> ApiResult<Option<AdminUser>> {
let response = self
.generated()
.list_admin_users(None, None, None, None, None, Some(query), None)
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
Ok(resp.users.into_iter().next())
}
pub async fn lookup_users_by_ids(&self, user_ids: &[String]) -> ApiResult<Vec<AdminUser>> {
if user_ids.is_empty() {
return Ok(vec![]);
+2 -15
View File
@@ -5,8 +5,8 @@ use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
CreateVoiceRegionResponse, CreateVoiceServerResponse, DeleteVoiceResponse,
GetVoiceRegionResponse, GetVoiceServerResponse, ListVoiceRegionsResponse,
ListVoiceServersResponse, UpdateVoiceRegionResponse, UpdateVoiceServerResponse,
GetVoiceRegionResponse, ListVoiceRegionsResponse, ListVoiceServersResponse,
UpdateVoiceRegionResponse, UpdateVoiceServerResponse,
};
impl AdminApiClient {
@@ -83,19 +83,6 @@ impl AdminApiClient {
self.generated_value(response.into_inner())
}
pub async fn get_voice_server(
&self,
region_id: &str,
server_id: &str,
) -> ApiResult<GetVoiceServerResponse> {
let response = self
.generated()
.get_admin_voice_server(region_id, server_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn create_voice_server(
&self,
params: &serde_json::Value,
+1 -158
View File
@@ -10,7 +10,6 @@ const DEFAULT_REPORTS_BUCKET_ORIGIN: &str = "https://fluxer-reports.ewr1.vultrob
#[derive(Clone, Debug)]
pub struct AdminConfig {
pub env: RuntimeEnv,
pub host: String,
pub port: u16,
pub secret_key_base: String,
@@ -35,13 +34,6 @@ pub struct ProxyConfig {
pub client_ip_header_name: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimeEnv {
Development,
Production,
Test,
}
impl AdminConfig {
pub fn from_env() -> anyhow::Result<Self> {
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
@@ -60,7 +52,6 @@ impl AdminConfig {
);
Ok(Self {
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
host: read_env("FLUXER_ADMIN_HOST", "0.0.0.0"),
port: read_env("FLUXER_ADMIN_PORT", "3020")
.parse()
@@ -105,14 +96,6 @@ impl AdminConfig {
})
}
pub fn is_dev(&self) -> bool {
self.env == RuntimeEnv::Development
}
pub fn is_production(&self) -> bool {
self.env == RuntimeEnv::Production
}
pub fn secure_cookies(&self) -> bool {
self.admin_endpoint.starts_with("https://")
}
@@ -189,16 +172,6 @@ fn is_virtual_hostable_bucket(bucket: &str, allow_dots: bool) -> bool {
&& !bucket.ends_with('-')
}
impl RuntimeEnv {
pub(crate) fn from_env_value(value: &str) -> Self {
match value {
"production" => Self::Production,
"test" => Self::Test,
_ => Self::Development,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -207,8 +180,7 @@ mod tests {
static ENV_LOCK: Mutex<()> = Mutex::new(());
const MANAGED_ENV: [&str; 15] = [
"FLUXER_ENV",
const MANAGED_ENV: [&str; 14] = [
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
@@ -243,135 +215,6 @@ mod tests {
config
}
#[test]
fn normalize_base_path_strips_trailing_slashes() {
assert_eq!(normalize_base_path("admin/"), "/admin");
assert_eq!(normalize_base_path("admin///"), "/admin");
}
#[test]
fn normalize_base_path_adds_leading_slash() {
assert_eq!(normalize_base_path("admin"), "/admin");
}
#[test]
fn normalize_base_path_empty_stays_empty() {
assert_eq!(normalize_base_path(""), "");
assert_eq!(normalize_base_path(" "), "");
assert_eq!(normalize_base_path("/"), "");
}
#[test]
fn normalize_base_path_preserves_inner() {
assert_eq!(normalize_base_path("/foo/bar/"), "/foo/bar");
}
#[test]
fn trim_trailing_slash_removes_trailing() {
assert_eq!(
trim_trailing_slash("https://example.com/"),
"https://example.com"
);
assert_eq!(
trim_trailing_slash("https://example.com"),
"https://example.com"
);
}
#[test]
fn trim_trailing_slash_empty_string() {
assert_eq!(trim_trailing_slash(""), "");
assert_eq!(trim_trailing_slash("/"), "");
}
#[test]
fn runtime_env_from_env_value() {
assert_eq!(
RuntimeEnv::from_env_value("production"),
RuntimeEnv::Production
);
assert_eq!(RuntimeEnv::from_env_value("test"), RuntimeEnv::Test);
assert_eq!(
RuntimeEnv::from_env_value("development"),
RuntimeEnv::Development
);
assert_eq!(
RuntimeEnv::from_env_value("anything"),
RuntimeEnv::Development
);
}
#[test]
fn is_production_returns_true_for_production() {
let config = AdminConfig {
env: RuntimeEnv::Production,
host: String::new(),
port: 3020,
secret_key_base: String::new(),
base_path: String::new(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: String::new(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
};
assert!(config.is_production());
assert!(!config.is_dev());
}
#[test]
fn is_dev_returns_true_for_development() {
let config = AdminConfig {
env: RuntimeEnv::Development,
host: String::new(),
port: 3020,
secret_key_base: String::new(),
base_path: String::new(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: String::new(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
};
assert!(config.is_dev());
assert!(!config.is_production());
}
#[test]
fn from_env_uses_defaults() {
let config = config_from_env(&[]);
assert_eq!(config.env, RuntimeEnv::Development);
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 3020);
assert_eq!(config.oauth_client_id, DEFAULT_ADMIN_OAUTH_CLIENT_ID);
assert_eq!(
config.oauth_redirect_uri,
"https://admin.fluxer.app/oauth2_callback"
);
}
#[test]
fn a_non_default_public_port_reaches_the_public_endpoints() {
let config = config_from_env(&[
-43
View File
@@ -13,43 +13,6 @@ pub fn asset(file_name: &str) -> Option<(&'static str, &'static [u8])> {
mod tests {
use super::*;
#[test]
fn stylesheet_is_served_and_content_hashed() {
let (content_type, bytes) =
asset(STYLESHEET_FILE_NAME).expect("the generated stylesheet must be servable");
assert_eq!(content_type, "text/css; charset=utf-8");
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
assert!(css.contains("font-family: 'Fluxer Sans'"));
assert!(css.contains("font-family: 'Fluxer Mono'"));
assert!(
!css.contains("?v="),
"content hashing replaces cache-bust tokens"
);
assert!(
!css.contains("fluxerstatic"),
"fonts must not be fetched from the static CDN"
);
assert!(
STYLESHEET_FILE_NAME.starts_with("fonts.") && STYLESHEET_FILE_NAME.ends_with(".css"),
"unexpected stylesheet name {STYLESHEET_FILE_NAME}"
);
}
#[test]
fn every_face_the_stylesheet_references_is_served() {
let (_, bytes) = asset(STYLESHEET_FILE_NAME).expect("stylesheet");
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
let mut referenced = 0;
for fragment in css.split("url('").skip(1) {
let file_name = fragment.split('\'').next().expect("unterminated url()");
let (content_type, _) = asset(file_name)
.unwrap_or_else(|| panic!("stylesheet references unserved font {file_name}"));
assert_eq!(content_type, "font/woff2");
referenced += 1;
}
assert_eq!(referenced, 16, "expected the 16 bundled Latin-core faces");
}
#[test]
fn ofl_attribution_ships_with_the_binaries() {
let notice = ASSETS
@@ -63,10 +26,4 @@ mod tests {
.any(|(name, _, _)| name.starts_with("LICENSE-IBM-PLEX."))
);
}
#[test]
fn unknown_files_are_not_served() {
assert!(asset("fonts.css").is_none());
assert!(asset("../../../etc/passwd").is_none());
}
}
+1 -2
View File
@@ -198,14 +198,13 @@ pub fn get_csrf_token(request: &Request) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv};
use crate::config::{AdminConfig, ProxyConfig};
use crate::state::AppState;
use axum::{Router, middleware::from_fn_with_state, routing::get};
use tower::ServiceExt;
fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState {
AppState::new(AdminConfig {
env: RuntimeEnv::Production,
host: String::new(),
port: 3020,
secret_key_base: "test-secret".to_owned(),
-63
View File
@@ -108,66 +108,3 @@ pub fn set_flash_cookie(response: &mut Response, flash: &FlashData, secure: bool
response.headers_mut().append(header::SET_COOKIE, v);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serialize_then_parse_round_trips() {
let flash = FlashData::success("Item saved");
let encoded = serialize_flash(&flash);
let decoded = parse_flash(&encoded).expect("must parse");
assert_eq!(decoded.message, "Item saved");
assert_eq!(decoded.flash_type, "success");
assert!(decoded.detail.is_none());
}
#[test]
fn parse_flash_returns_none_for_invalid_base64() {
assert!(parse_flash("!!!not-base64!!!").is_none());
}
#[test]
fn parse_flash_returns_none_for_invalid_json() {
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json");
assert!(parse_flash(&encoded).is_none());
}
#[test]
fn flash_success_sets_correct_type() {
let f = FlashData::success("ok");
assert_eq!(f.flash_type, "success");
assert_eq!(f.message, "ok");
}
#[test]
fn flash_error_sets_correct_type() {
let f = FlashData::error("fail");
assert_eq!(f.flash_type, "error");
assert_eq!(f.message, "fail");
}
#[test]
fn flash_info_sets_correct_type() {
let f = FlashData::info("note");
assert_eq!(f.flash_type, "info");
assert_eq!(f.message, "note");
}
#[test]
fn to_flash_message_maps_levels() {
assert!(matches!(
FlashData::success("").to_flash_message().level,
FlashLevel::Success
));
assert!(matches!(
FlashData::error("").to_flash_message().level,
FlashLevel::Error
));
assert!(matches!(
FlashData::info("").to_flash_message().level,
FlashLevel::Info
));
}
}
@@ -127,181 +127,3 @@ fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
_ => left.id.cmp(&right.id),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_message_and_attachment_fields() {
let value = json!({
"id": "9007199254740993",
"content": "Message content",
"timestamp": "2026-09-11T12:00:00Z",
"author_id": 42,
"author_username": "alice",
"author_global_name": "Alice",
"author_discriminator": 1234,
"author_avatar": "avatar-hash",
"channel_id": "100",
"channel_nsfw": false,
"channel_content_warning_level": 2,
"channel_content_warning_text": "Content warning",
"guild_nsfw": true,
"attachments": [{
"id": 9007199254740993_u64,
"url": "https://cdn.example.com/image.png",
"filename": "image.png",
"nsfw": true,
"content_type": "image/png",
"width": 640,
"height": 480,
"size": 4096
}]
});
assert_eq!(
message_from_value(&value),
Message {
id: "9007199254740993".into(),
content: "Message content".into(),
timestamp: "2026-09-11T12:00:00Z".into(),
author_id: "42".into(),
author_username: "alice".into(),
author_global_name: Some("Alice".into()),
author_discriminator: "1234".into(),
author_avatar: Some("avatar-hash".into()),
webhook_id: None,
author_bot: None,
channel_id: "100".into(),
channel_nsfw: Some(false),
channel_content_warning_level: Some(2),
channel_content_warning_text: Some("Content warning".into()),
guild_nsfw: Some(true),
attachments: vec![Attachment {
id: "9007199254740993".into(),
url: "https://cdn.example.com/image.png".into(),
filename: "image.png".into(),
nsfw: Some(true),
content_type: Some("image/png".into()),
width: Some(640),
height: Some(480),
size: Some(4096),
}],
missing_attachments: vec![],
}
);
}
#[test]
fn maps_author_bot_flags() {
let bot = message_from_value(&json!({"author_id": "42", "author_bot": true}));
assert_eq!(bot.author_bot, Some(true));
let human = message_from_value(&json!({"author_id": "43", "author_bot": false}));
assert_eq!(human.author_bot, Some(false));
let webhook = message_from_value(
&json!({"author_id": "500", "webhook_id": "500", "author_bot": null}),
);
assert_eq!(webhook.author_bot, None);
let old = message_from_value(&json!({"author_id": "44"}));
assert_eq!(old.author_bot, None);
}
#[test]
fn maps_missing_attachments() {
let message = message_from_value(&json!({
"id": "10",
"attachments": [],
"missing_attachments": [{
"id": 9007199254740993_u64,
"filename": "evidence.png",
"nsfw": null,
"content_type": "image/png",
"width": 640,
"height": 480,
"size": 4096
}]
}));
assert!(message.attachments.is_empty());
assert_eq!(
message.missing_attachments,
vec![MissingAttachment {
id: "9007199254740993".into(),
filename: "evidence.png".into(),
content_type: Some("image/png".into()),
size: Some(4096),
}]
);
let old = message_from_value(&json!({"id": "11", "attachments": []}));
assert!(old.missing_attachments.is_empty());
let null = message_from_value(&json!({"id": "12", "missing_attachments": null}));
assert!(null.missing_attachments.is_empty());
}
#[test]
fn maps_webhook_authors() {
let webhook = message_from_value(&json!({
"author_id": "500",
"author_username": "Harbor Bulletin",
"webhook_id": "500"
}));
assert_eq!(webhook.webhook_id.as_deref(), Some("500"));
assert_eq!(webhook.author_id, "500");
let user = message_from_value(&json!({"author_id": "42", "webhook_id": null}));
assert_eq!(user.webhook_id, None);
}
#[test]
fn retains_display_defaults_for_incomplete_snapshots() {
let message = message_from_value(&json!({"attachments": [{}]}));
assert_eq!(message.author_username, "Unknown");
assert_eq!(message.author_discriminator, "0000");
assert_eq!(message.content, "");
assert_eq!(message.author_global_name, None);
assert_eq!(message.webhook_id, None);
assert_eq!(message.channel_nsfw, None);
assert_eq!(
message.attachments,
vec![Attachment {
id: String::new(),
url: String::new(),
filename: String::new(),
nsfw: None,
content_type: None,
width: None,
height: None,
size: None,
}]
);
}
#[test]
fn orders_string_and_numeric_snowflakes_without_rounding() {
let values = vec![
json!({"id": "9007199254740993"}),
json!({"id": "10"}),
json!({"id": 2}),
json!({"id": 9007199254740992_u64}),
];
let messages = ordered_messages(&values);
let ids: Vec<&str> = messages.iter().map(|message| message.id.as_str()).collect();
assert_eq!(ids, ["2", "10", "9007199254740992", "9007199254740993"]);
assert_eq!(values[0]["id"], "9007199254740993");
assert!(ordered_messages(&[]).is_empty());
}
#[test]
fn preserves_message_order_when_ids_are_equal() {
let values = [
json!({"id": "10", "content": "first"}),
json!({"id": 10, "content": "second"}),
];
let messages = ordered_messages(&values);
assert_eq!(messages[0].content, "first");
assert_eq!(messages[1].content, "second");
}
}
@@ -51,94 +51,3 @@ pub fn report_category(category: &str) -> Markup {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_categories_get_their_label() {
assert_eq!(
report_category_label("harassment"),
"Harassment or bullying"
);
assert_eq!(report_category_label("spam"), "Spam");
assert_eq!(report_category_label("spam_account"), "Spam account");
assert_eq!(
report_category_label("extremist_community"),
"Extremist community"
);
}
#[test]
fn unknown_categories_fall_back_to_the_key() {
assert_eq!(report_category_label("future_value"), "future_value");
assert_eq!(report_category_label(""), "");
}
#[test]
fn reasons_that_repeat_the_category_label_are_detected() {
assert!(reason_repeats_category("spam", "Spam"));
assert!(reason_repeats_category("spam", "spam"));
assert!(reason_repeats_category(
"harassment",
"Harassment or bullying"
));
assert!(reason_repeats_category(
"harassment",
" harassment OR bullying "
));
assert!(!reason_repeats_category("harassment", "Hate speech"));
assert!(!reason_repeats_category(
"child_safety",
"Child sexual abuse material"
));
assert!(reason_repeats_category("future_value", "future_value"));
assert!(!reason_repeats_category("future_value", "Spam"));
}
#[test]
fn every_category_key_is_unique_and_labelled() {
let mut keys = REPORT_CATEGORIES
.iter()
.map(|(key, _)| *key)
.collect::<Vec<_>>();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), REPORT_CATEGORIES.len());
assert_eq!(keys.len(), 18);
for (key, label) in REPORT_CATEGORIES {
assert_ne!(key, label);
}
}
#[test]
fn options_start_with_all_and_keep_an_unknown_selection() {
let options = report_category_options("");
assert_eq!(options.first(), Some(&("", "All")));
assert_eq!(options.len(), 19);
assert_eq!(report_category_options("spam").len(), 19);
let unknown = report_category_options("future_value");
assert_eq!(unknown.len(), 20);
assert_eq!(unknown.last(), Some(&("future_value", "future_value")));
}
#[test]
fn category_markup_shows_the_label_and_keeps_the_key() {
let markup = report_category("doxxing").into_string();
assert!(
markup.contains(r#"data-report-category="doxxing""#),
"{markup}"
);
assert!(
markup.contains(">Sharing personal information<"),
"{markup}"
);
let unknown = report_category("future_value").into_string();
assert!(
unknown.contains(r#"data-report-category="future_value""#),
"{unknown}"
);
assert!(unknown.contains(">future_value<"), "{unknown}");
}
}
@@ -74,20 +74,3 @@ pub fn info(base: &str, hint: &Hint<'_>) -> Markup {
}
}
}
#[cfg(test)]
mod tests {
use super::HintLink;
#[test]
#[should_panic(expected = "anchor")]
fn rejects_a_link_that_points_at_no_anchor() {
let _ = HintLink::new("/instance-config", "Instance policy");
}
#[test]
#[should_panic(expected = "label")]
fn rejects_a_link_with_no_label() {
let _ = HintLink::new("/instance-config#community-creation", " ");
}
}
@@ -16,31 +16,3 @@ pub fn format_user_display(
_ => "Unknown".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::user_tag::sync_with_unique_usernames;
#[test]
fn username_instances_show_bare_human_names_and_keep_bot_tags() {
sync_with_unique_usernames(true, || {
assert_eq!(
format_user_display(None, Some("alice"), Some("0000"), false),
"alice"
);
assert_eq!(
format_user_display(None, Some("helper"), Some("4363"), true),
"helper#4363"
);
});
}
#[test]
fn email_instances_keep_the_zero_tag() {
assert_eq!(
format_user_display(None, Some("alice"), Some("0000"), false),
"alice#0000"
);
}
}
@@ -103,38 +103,3 @@ pub fn user_profile_badges(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(self_hosted: bool, name: Option<&str>, premium_type: i32) -> String {
user_profile_badges(
"https://static.example.com",
0,
Some(premium_type),
Some("2026-01-01"),
self_hosted,
name,
false,
)
.into_string()
}
#[test]
fn hosted_premium_badges_keep_their_fluxer_labels() {
assert!(
render(false, Some("Gold"), 1).contains("Fluxer Plutonium subscriber since 2026-01-01")
);
assert!(render(false, None, 2).contains("Fluxer Visionary since 2026-01-01"));
}
#[test]
fn self_hosted_premium_badges_use_the_configured_name() {
let markup = render(true, Some("Gold"), 1);
assert!(markup.contains("Gold subscriber since 2026-01-01"));
assert!(!markup.contains("Plutonium"));
assert!(render(true, Some("Gold"), 2).contains("Gold subscriber since"));
assert!(!render(true, None, 1).contains("img"));
}
}
@@ -29,7 +29,6 @@ pub fn render_sidebar(
nav data-sidebar-nav="" class="sidebar-scrollbar flex-1 space-y-4 overflow-y-auto p-4" aria-label="Admin sections" {
@for section in NAV_SECTIONS {
@let visible_items: Vec<_> = section.items.iter()
.filter(|item| !(item.hosted_only && config.self_hosted))
.filter(|item| acl::has_any_permission(admin_acls, item.required_acls))
.filter(|item| {
item.active_key != "voice-servers" || inspected_voice_region_id.is_some()
@@ -7,7 +7,6 @@ pub struct NavItem {
pub path: &'static str,
pub active_key: &'static str,
pub required_acls: &'static [&'static str],
pub hosted_only: bool,
}
pub struct NavSection {
@@ -17,10 +16,7 @@ pub struct NavSection {
macro_rules! item {
($t:expr, $p:expr, $k:expr, [ $($a:expr),+ $(,)? ]) => {
NavItem { title: $t, path: $p, active_key: $k, required_acls: &[$($a),+], hosted_only: false }
};
($t:expr, $p:expr, $k:expr, [ $($a:expr),+ $(,)? ], hosted) => {
NavItem { title: $t, path: $p, active_key: $k, required_acls: &[$($a),+], hosted_only: true }
NavItem { title: $t, path: $p, active_key: $k, required_acls: &[$($a),+] }
};
}
@@ -253,26 +249,3 @@ pub const NAV_SECTIONS: &[NavSection] = &[
)],
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bulk_actions_nav_covers_every_acl_the_page_renders_a_section_for() {
let item = NAV_SECTIONS
.iter()
.flat_map(|section| section.items)
.find(|item| item.active_key == "bulk-actions")
.expect("bulk actions nav item");
for required in [
acl::BULK_UPDATE_USER_FLAGS,
acl::BULK_UPDATE_GUILD_FEATURES,
acl::BULK_ADD_GUILD_MEMBERS,
acl::BULK_DELETE_USERS,
acl::BULK_DELETE_USER_MESSAGES,
] {
assert!(item.required_acls.contains(&required), "{required}");
}
}
}
@@ -171,48 +171,3 @@ pub fn audit_logs_page(
};
admin_layout(config, auth, "Audit Logs", "audit-logs", None, content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_type_filter_offers_bulk_jobs() {
let params = AuditLogsParams {
query: "",
admin_user_id: "",
target_id: "",
target_type: "bulk_job",
access: "",
sort_by: "createdAt",
sort_order: "desc",
limit: 50,
current_page: 0,
};
let markup = filters_section("/admin", &params).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", &params).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, &params),
"/admin/audit-logs?page=1&target_id=1500000000000000001&access=read&sort_by=createdAt&sort_order=desc&limit=50"
);
}
}
@@ -326,58 +326,3 @@ pub fn audit_log_table_body(base: &str, entries: &[AuditLogEntry]) -> Markup {
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(target_type: &str, target_id: &str) -> AuditLogEntry {
serde_json::from_value(serde_json::json!({
"log_id": "1900000000000000001",
"admin_user_id": "1500000000000000001",
"action": "bulk_schedule_deletion",
"target_id": target_id,
"target_type": target_type,
"audit_log_reason": "Raid cleanup",
"created_at": "2026-09-14T12:00:00.000Z"
}))
.expect("audit log fixture must deserialize")
}
#[test]
fn bulk_job_targets_link_to_the_job_detail_page() {
let markup = target_cell("/admin", &entry("bulk_job", "1900000000000000002")).into_string();
assert!(markup.contains(r#"href="/admin/jobs/1900000000000000002""#));
assert!(markup.contains("Bulk job"));
assert!(!markup.contains("/admin/users/"));
}
#[test]
fn admin_labels_drop_the_zero_tag_only_without_tags() {
let admin = AuditLogUserSummary {
id: "1500000000000000001".to_owned(),
username: "lilith".to_owned(),
discriminator: "0".to_owned(),
global_name: Some("Lilith".to_owned()),
};
assert_eq!(user_label(&admin), "Lilith (lilith#0000)");
crate::utils::user_tag::sync_with_unique_usernames(true, || {
assert_eq!(user_label(&admin), "Lilith (lilith)");
});
}
#[test]
fn unknown_target_types_stay_unlinked() {
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
assert!(!markup.contains("<a "));
assert!(markup.contains("Email domain"));
}
#[test]
fn retired_action_names_still_render() {
let mut retired = entry("user", "1500000000000000002");
retired.action = "update_retired_toggle".to_string();
let markup = audit_log_table_body("/admin", &[retired]).into_string();
assert!(markup.contains("Update retired toggle"));
}
}
@@ -418,30 +418,6 @@ fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
mod tests {
use super::*;
#[test]
fn add_grid_offers_only_the_opt_in_clone_features() {
let markup = guild_feature_checkbox_grid("add_features[]", false).into_string();
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
assert!(markup.contains(r#"value="CLONE_STICKER_ENABLED""#));
assert!(!markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
}
#[test]
fn deletion_form_has_no_preselected_reason() {
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
for (value, _) in DELETION_REASONS {
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
}
}
#[test]
fn deletion_form_defaults_to_the_moderation_retention_floor() {
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
}
#[test]
fn deletion_form_emails_each_user_by_default() {
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
@@ -456,13 +432,4 @@ mod tests {
assert!(!deletion.contains(r#"name="notify_user" value="true""#));
assert!(deletion.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
assert!(markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
assert!(markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, removal only)"));
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
}
}
@@ -139,39 +139,3 @@ pub fn gift_codes_page(
};
admin_layout(config, auth, "Gift Codes", "gift-codes", None, content)
}
#[cfg(test)]
mod tests {
use super::*;
fn branding(name: &str, premium_enabled: bool) -> PremiumBranding {
PremiumBranding {
name: Some(name.to_owned()),
premium_enabled,
}
}
#[test]
fn premium_name_comes_from_branding_with_per_deployment_fallbacks() {
let hosted = GiftCodesPremium::from_branding(false, None);
assert_eq!(hosted.name, "Plutonium");
assert!(!hosted.needs_mirror_mode);
let self_hosted = GiftCodesPremium::from_branding(true, None);
assert_eq!(self_hosted.name, "Premium");
assert!(!self_hosted.needs_mirror_mode);
let gold = GiftCodesPremium::from_branding(true, Some(&branding("Gold", true)));
assert_eq!(gold.name, "Gold");
assert!(!gold.needs_mirror_mode);
}
#[test]
fn everyone_mode_is_only_flagged_on_self_hosted_instances() {
assert!(
GiftCodesPremium::from_branding(true, Some(&branding("Gold", false))).needs_mirror_mode
);
assert!(
!GiftCodesPremium::from_branding(false, Some(&branding("Plutonium", false)))
.needs_mirror_mode
);
}
}
@@ -182,56 +182,3 @@ fn filtered_features() -> Vec<&'static str> {
.copied()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn guild_with_features(features: &[&str]) -> GuildInfo {
serde_json::from_value(serde_json::json!({
"id": "1600000000000000001",
"name": "Test Guild",
"icon": null,
"banner": null,
"owner_id": "1500000000000000001",
"owner_username": null,
"owner_global_name": null,
"owner_discriminator": null,
"features": features,
"nsfw_level": null,
"nsfw": null,
"content_warning_level": null,
"content_warning_text": null,
"description": null,
"vanity_url_code": null,
}))
.expect("guild fixture")
}
#[test]
fn editor_offers_the_opt_in_clone_features() {
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
}
#[test]
fn editor_keeps_the_deprecated_clone_features_clearable() {
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_DISABLED"));
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_DISABLED"));
assert_eq!(
feature_label("CLONE_EMOJI_DISABLED"),
"CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"
);
assert_eq!(feature_label("CLONE_EMOJI_ENABLED"), "CLONE_EMOJI_ENABLED");
}
#[test]
fn readonly_view_marks_a_stale_flag_as_deprecated() {
let guild = guild_with_features(&["CLONE_EMOJI_DISABLED", "CLONE_STICKER_ENABLED"]);
let markup = features_tab_readonly(&guild, GUILD_FEATURES).into_string();
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"));
assert!(markup.contains("CLONE_STICKER_ENABLED"));
}
}
@@ -110,49 +110,3 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn guild_report_rows_format_the_date_and_wrap_long_cells() {
let report: ReportEntry = serde_json::from_value(json!({
"report_id": "1800000000000000006",
"reporter_id": "1500000000000000000",
"reporter_username": "reporter_0423a7212d56",
"reporter_discriminator": "8650",
"reporter_global_name": "Avery Reporter",
"reported_at": "2026-10-05T23:23:28.067Z",
"status": 0,
"report_type": 0,
"category": "spam",
"reason": "spam",
"reason_label": "Spam",
"reported_guild_id": "1700000000000000800"
}))
.expect("valid report");
let markup = report_row("/admin", &report).into_string();
assert!(markup.contains("Oct 5, 2026, 11:23 PM UTC"), "{markup}");
assert!(!markup.contains("2026-10-05T23:23:28.067Z"), "{markup}");
assert!(
markup
.contains(r#"<span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
"{markup}"
);
assert!(markup.contains("Avery Reporter "), "{markup}");
assert!(
markup.contains(r#"<td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell">Pending</td>"#),
"{markup}"
);
assert!(
markup.contains(r#"<div class="mb-1 text-neutral-900 xl:hidden" data-status-compact="1800000000000000006">Pending</div>"#),
"{markup}"
);
assert!(markup.contains(">Pending<"), "{markup}");
assert!(markup.contains(">View<"), "{markup}");
assert_eq!(markup.matches(">Spam<").count(), 2, "{markup}");
assert!(markup.contains(r#"data-report-reason="spam""#), "{markup}");
}
}
@@ -294,32 +294,3 @@ fn readonly_field(label: &str, value: &str) -> Markup {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn guild() -> GuildDetailInfo {
serde_json::from_value(json!({
"id": "1500000000000000001",
"owner_id": "1400000000000000001",
"name": "Guild",
"verification_level": 1
}))
.expect("valid guild detail")
}
#[test]
fn low_verification_names_a_claimed_account_in_username_mode() {
let markup = settings_tab_readonly(&guild(), true).into_string();
assert!(markup.contains("Low (claimed account)"));
assert!(!markup.contains("verified email"));
}
#[test]
fn low_verification_names_a_verified_email_in_email_mode() {
let markup = settings_tab_readonly(&guild(), false).into_string();
assert!(markup.contains("Low (verified email)"));
}
}
@@ -464,189 +464,3 @@ pub fn premium_billing_section(
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn operator_billing() -> InstanceBillingResponse {
InstanceBillingResponse {
enabled: Some(true),
effective_enabled: true,
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
stripe_secret_key_stored: true,
stripe_webhook_secret_stored: true,
default_currency: Some("GBP".to_owned()),
prices: Some(BTreeMap::from([(
"GBP".to_owned(),
BillingPriceSet {
monthly: Some("price_1GbpM".to_owned()),
yearly: Some("price_1GbpY".to_owned()),
gift_1_month: None,
gift_1_year: Some("price_1GbpG".to_owned()),
},
)])),
country_currencies: Some(BTreeMap::from([
("GB".to_owned(), "GBP".to_owned()),
("IE".to_owned(), "GBP".to_owned()),
])),
legacy_prices: Some(BTreeMap::from([(
"monthly_GBP".to_owned(),
vec!["price_1OldA".to_owned(), "price_1OldB".to_owned()],
)])),
billing_active: true,
stripe_serviceable: true,
catalog_mode: BillingCatalogMode::Operator,
webhook_url: "https://api.example.com/stripe/webhook".to_owned(),
automatic_tax: None,
tax_id_collection: Some(true),
terms_consent_required: Some(false),
effective_automatic_tax: false,
effective_tax_id_collection: true,
effective_terms_consent_required: false,
}
}
fn branding(name: &str) -> AppBrandingConfigResponse {
AppBrandingConfigResponse {
premium_product_name: name.to_owned(),
premium_info_url: Some("https://example.com/gold".to_owned()),
..Default::default()
}
}
#[test]
fn section_renders_every_field_and_one_empty_price_row() {
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Gold"),
&operator_billing(),
PremiumMode::Mirror,
)
.into_string();
assert!(markup.contains("action=\"/admin/instance-config?action=update_billing\""));
assert!(markup.contains("data-admin-result-form=\"true\""));
assert!(markup.contains("<option value=\"on\" selected>On</option>"));
assert!(markup.contains("name=\"billing_automatic_tax\""));
assert!(markup.contains("name=\"billing_tax_id_collection\""));
assert!(markup.contains("name=\"billing_terms_consent_required\""));
assert!(markup.contains("Customer portal"));
assert!(markup.contains("name=\"billing_premium_product_name\""));
assert!(markup.contains("value=\"Gold\""));
assert!(markup.contains("value=\"https://example.com/gold\""));
assert!(markup.contains("name=\"billing_enabled\""));
assert!(markup.contains("type=\"password\" id=\"billing_stripe_secret_key\""));
assert!(markup.contains("name=\"billing_clear_stripe_secret_key\""));
assert!(markup.contains("name=\"billing_clear_stripe_webhook_secret\""));
assert!(markup.contains("value=\"https://api.example.com/stripe/webhook\""));
assert!(markup.contains("Billing active"));
assert!(!markup.contains("Purchases are unavailable"));
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 2);
assert_eq!(
markup.matches("name=\"billing_price_gift_1_year\"").count(),
2
);
assert!(markup.contains("value=\"price_1GbpG\""));
assert!(markup.contains("GB=GBP\nIE=GBP"));
assert!(markup.contains("monthly_GBP=price_1OldA\nmonthly_GBP=price_1OldB"));
assert!(!markup.contains("Plutonium"));
assert!(!markup.contains("sk_"));
}
#[test]
fn unset_secrets_have_no_clear_checkbox() {
let billing = InstanceBillingResponse::default();
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Premium"),
&billing,
PremiumMode::Everyone,
)
.into_string();
assert!(!markup.contains("billing_clear_stripe_secret_key"));
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
assert_eq!(markup.matches("name=\"billing_price_currency\"").count(), 1);
assert!(markup.contains("Switch the premium model to Mirror"));
assert!(markup.contains("Catalog: environment"));
}
#[test]
fn env_secrets_are_labelled_and_cannot_be_cleared() {
let billing = InstanceBillingResponse {
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
..Default::default()
};
let markup = premium_billing_section(
"/admin",
"csrf",
&branding("Premium"),
&billing,
PremiumMode::Mirror,
)
.into_string();
assert!(markup.contains("Stripe secret key from environment"));
assert!(markup.contains("Webhook secret from environment"));
assert!(markup.contains("Set from the environment"));
assert!(!markup.contains("billing_clear_stripe_secret_key"));
assert!(!markup.contains("billing_clear_stripe_webhook_secret"));
}
#[test]
fn tri_state_selects_reflect_the_stored_value() {
let render = |stored| {
tri_state_select(
"billing_enabled",
"Billing",
"Use environment setting",
stored,
"",
)
.into_string()
};
assert!(
render(None)
.contains("<option value=\"default\" selected>Use environment setting</option>")
);
assert!(render(Some(true)).contains("<option value=\"on\" selected>On</option>"));
assert!(render(Some(false)).contains("<option value=\"off\" selected>Off</option>"));
}
#[test]
fn blockers_explain_why_billing_is_inactive() {
let mut billing = InstanceBillingResponse::default();
assert_eq!(
billing_blockers(&billing, PremiumMode::Everyone),
vec![
"the premium model is Everyone, so there is no paid tier to sell",
"billing is not enabled",
"no Stripe secret key is set",
]
);
billing.effective_enabled = true;
billing.stripe_secret_key_set = true;
assert_eq!(
billing_blockers(&billing, PremiumMode::Mirror),
vec![
"the environment price catalog has no currency with both a monthly and a yearly price ID"
]
);
billing.catalog_mode = BillingCatalogMode::Operator;
billing.prices = Some(BTreeMap::from([(
"GBP".to_owned(),
BillingPriceSet {
monthly: Some("price_1A".to_owned()),
..Default::default()
},
)]));
assert_eq!(
billing_blockers(&billing, PremiumMode::Mirror),
vec!["no currency has both a monthly and a yearly price ID"]
);
assert!(billing_blockers(&operator_billing(), PremiumMode::Mirror).is_empty());
}
}
@@ -2142,224 +2142,3 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn username_instances_hide_email_delivery_and_the_smtp_test() {
let integrations = InstanceIntegrationsResponse::default();
let username = integrations_config_section(
"/admin",
"csrf",
&integrations,
AccountIdentityMode::Username,
"Fluxer",
)
.into_string();
assert!(!username.contains("Email delivery"));
assert!(!username.contains("test_smtp"));
assert!(!username.contains("integration_email_present"));
assert!(username.contains("Bluesky OAuth"));
let email = integrations_config_section(
"/admin",
"csrf",
&integrations,
AccountIdentityMode::Email,
"Fluxer",
)
.into_string();
assert!(email.contains("Email delivery"));
assert!(email.contains("test_smtp"));
assert!(email.contains(r#"name="integration_email_present" value="1""#));
}
#[test]
fn account_identity_section_has_no_tag_choice_in_username_mode() {
let markup = account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Username,
locked: Some(true),
tag_style: TagStyle::None,
})
.into_string();
assert!(markup.contains("Sign-in Method"));
assert!(markup.contains("Username"));
assert!(markup.contains("Fixed"));
assert!(!markup.contains("No tags"));
assert!(!markup.contains("Random tags"));
assert!(!markup.contains("username tags"));
assert!(!markup.contains("<form"));
assert!(!markup.contains("<input"));
}
#[test]
fn account_identity_section_shows_random_tags_in_email_mode() {
let markup = account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Email,
locked: Some(true),
tag_style: TagStyle::Random,
})
.into_string();
assert!(markup.contains("Random tags"));
assert!(!markup.contains("No tags"));
assert!(markup.contains("username tags"));
}
#[test]
fn account_identity_section_shows_no_tags_in_email_mode() {
let markup = account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Email,
locked: Some(true),
tag_style: TagStyle::None,
})
.into_string();
assert!(markup.contains("No tags"));
assert!(!markup.contains("Random tags"));
assert!(!markup.contains("<input"));
}
#[test]
fn account_identity_section_shows_the_lock_state_only_when_known() {
let render = |locked| {
account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Email,
locked,
tag_style: TagStyle::Random,
})
.into_string()
};
let unlocked = render(Some(false));
assert!(unlocked.contains("Not fixed yet"));
let unknown = render(None);
assert!(!unknown.contains("Fixed"));
assert!(!unknown.contains("Not fixed yet"));
assert!(unknown.contains("Random tags"));
}
#[test]
fn captcha_section_posts_the_switch_and_difficulty_fields() {
let markup =
captcha_section("/admin", "csrf", &CaptchaConfigResponse::default()).into_string();
assert!(markup.contains("/admin/instance-config?action=update_captcha"));
assert!(markup.contains(r#"name="captcha_enabled""#));
assert!(markup.contains(r#"name="captcha_cost""#));
assert!(markup.contains(r#"name="captcha_max_counter""#));
assert!(markup.contains("about 3.6 s"));
}
#[test]
fn low_end_solve_estimate_at_the_defaults_is_about_three_and_a_half_seconds() {
let seconds = estimate_low_end_solve_seconds(5_000, 1_000);
assert!((seconds - 3.57).abs() < 0.01, "{seconds}");
}
#[test]
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
let domain_migration = DomainMigrationConfigResponse {
anonymous_rollout_basis_points: 250,
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..DomainMigrationConfigResponse::default()
};
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
assert!(markup.contains("action=update_domain_migration"));
assert!(markup.contains("domain_migration_enabled"));
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
assert!(markup.contains("name=\"domain_migration_rollout_country_codes\""));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("at the cap"));
}
#[test]
fn voice_p2p_section_shows_the_rollout_and_list_counts() {
let voice_p2p = VoiceP2pConfigResponse {
enabled: true,
config_version: 3,
rollout_basis_points: 250,
rollout_country_codes: vec!["SE".to_owned(), "NO".to_owned()],
max_participants: 3,
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..VoiceP2pConfigResponse::default()
};
let markup = voice_p2p_section("/admin", "csrf", &voice_p2p).into_string();
assert!(markup.contains("Peer-to-peer voice"));
assert!(markup.contains("action=update_voice_p2p"));
assert!(markup.contains("name=\"voice_p2p_enabled\""));
assert!(markup.contains("name=\"voice_p2p_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"voice_p2p_max_participants\""));
assert!(markup.contains("value=\"3\""));
assert!(markup.contains("min=\"2\""));
assert!(markup.contains("max=\"4\""));
assert!(markup.contains("name=\"voice_p2p_include_premium_users\""));
assert!(markup.contains("name=\"voice_p2p_included_guild_ids\""));
assert!(markup.contains("name=\"voice_p2p_rollout_country_codes\""));
assert!(markup.contains("value=\"SE, NO\""));
assert!(markup.contains("2 of 250 stored"));
assert!(markup.contains("Config version 3"));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("anonymous_rollout_basis_points"));
assert!(!markup.contains("standalone_forwarding"));
}
#[test]
fn push_relay_section_shows_the_consent_toggle() {
let accepted = PushRelayConfigResponse {
relay_consent_accepted: true,
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
};
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
assert!(markup.contains("action=update_push_relay"));
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
assert!(markup.contains("https://fluxer.com/push-relay"));
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
assert!(markup.contains("value=\"1130650140672000000\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
assert!(!markup.to_lowercase().contains("rollout"));
let unaccepted =
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
assert!(unaccepted.contains("Not accepted"));
assert!(unaccepted.contains("value=\"Never\""));
assert!(unaccepted.contains("value=\"Nobody\""));
}
#[test]
fn premium_mode_options_use_the_configured_premium_name() {
let markup =
premium_mode_form("/admin", "csrf", &InstancePolicyResponse::default(), "Gold")
.into_string();
assert!(markup.contains("Mirror (Free and Gold tiers)"));
assert!(markup.contains("Everyone (every member gets Gold limits)"));
assert!(!markup.contains("Plutonium"));
}
#[test]
fn domain_migration_section_flags_a_list_at_its_cap() {
let domain_migration = DomainMigrationConfigResponse {
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect(),
..DomainMigrationConfigResponse::default()
};
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
assert!(markup.contains("1000 of 1000 stored"));
assert!(markup.contains("at the cap"));
}
}
@@ -196,22 +196,3 @@ pub(crate) fn next_page_link(
}
}
}
#[cfg(test)]
mod tests {
use super::status_badge;
#[test]
fn status_badges_use_us_spelling_and_readable_labels() {
let canceled = status_badge("cancelled").into_string();
assert!(canceled.contains(">Canceled<"));
assert!(!canceled.contains("Cancelled"));
assert!(
status_badge("deadletter")
.into_string()
.contains(">Dead-letter<")
);
assert!(status_badge("running").into_string().contains(">Running<"));
assert!(status_badge("mystery").into_string().contains(">mystery<"));
}
}
File diff suppressed because it is too large Load Diff
@@ -640,126 +640,3 @@ fn reports_url(config: &AdminConfig, filters: &ReportFilters<'_>, page: u32, lim
.join("&");
format!("{}/reports?{}", config.base_path, query)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ProxyConfig, RuntimeEnv};
use serde_json::json;
fn test_config() -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: String::new(),
port: 0,
secret_key_base: "test-secret".to_owned(),
base_path: "/admin".to_owned(),
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
fn report() -> ReportEntry {
let mut value = json!({
"report_id": "1556709309709027556",
"reporter_id": "1556709306000000001",
"reporter_username": "reporter_80f33d09e88a",
"reporter_global_name": "Avery Reporter",
"reporter_discriminator": "5193",
"reported_at": "2026-10-06T04:33:00Z",
"status": 0,
"report_type": 1,
"category": "harassment",
"reported_user_id": "1556709306000000002",
"reported_user_username": "target_bc57b33ca5c4",
"reported_user_discriminator": "8316"
});
for key in [
"reporter_tag",
"reporter_email",
"reporter_full_legal_name",
"reporter_country_of_residence",
"additional_info",
"reported_user_tag",
"reported_user_global_name",
"reported_user_avatar_hash",
"reported_guild_id",
"reported_guild_name",
"reported_guild_icon_hash",
"reported_message_id",
"reported_channel_id",
"reported_channel_name",
"reported_channel_nsfw",
"reported_guild_invite_code",
"reported_guild_nsfw_level",
"reported_guild_nsfw",
"reported_guild_content_warning_level",
"reported_guild_content_warning_text",
"reported_channel_nsfw_override",
"reported_channel_content_warning_level",
"reported_channel_content_warning_text",
"reported_channel_effective_nsfw",
"reported_channel_effective_content_warning_level",
"reported_channel_effective_content_warning_text",
"resolved_at",
"resolved_by_admin_id",
"public_comment",
"mutual_dm_channel_id",
"message_context",
] {
value[key] = serde_json::Value::Null;
}
serde_json::from_value(value).expect("report fixture")
}
#[test]
fn narrow_layout_moves_date_and_status_into_visible_columns() {
let markup = render_reports_table(&test_config(), &[report()]).into_string();
assert!(markup.contains(
r#"<span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact="1556709309709027556">Oct 6, 2026, 4:33 AM UTC</span>"#
));
assert!(markup.contains(
r#"<span class="xl:hidden" data-status-pill-compact="1556709309709027556">"#
));
assert!(markup.contains(r#"<span data-status-pill="1556709309709027556">"#));
assert_eq!(
markup
.matches("hidden whitespace-nowrap xl:table-cell")
.count(),
2
);
assert_eq!(
markup
.matches(
"hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell"
)
.count(),
1
);
assert_eq!(
markup
.matches("hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell")
.count(),
1
);
assert_eq!(
markup
.matches("[overflow-wrap:anywhere] [&amp;_.whitespace-nowrap]:whitespace-normal")
.count(),
2
);
}
}
@@ -173,47 +173,3 @@ fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
}))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(value: &str, match_subdomains: bool) -> BlocklistEntry {
BlocklistEntry {
value: value.to_owned(),
match_subdomains: Some(match_subdomains),
category: Some("manual".to_owned()),
created_at: None,
}
}
#[test]
fn entries_table_lists_patterns_with_remove_forms() {
let page = BlocklistEntryPage {
items: vec![
entry("*shop*.example.com", false),
entry("store.example.com", true),
],
has_more: true,
next_after: Some("store.example.com".to_owned()),
};
let markup = entries_table("/admin", "token", &page).into_string();
assert!(markup.contains("*shop*.example.com"));
assert!(markup.contains(">Pattern</span>"));
assert!(markup.contains(">Domain</span>"));
assert!(markup.contains(r#"name="domain" value="*shop*.example.com""#));
assert!(markup.contains("/admin/url-domain-bans?action=unban&amp;_csrf=token"));
assert!(markup.contains("/admin/url-domain-bans?after=store.example.com"));
}
#[test]
fn entries_table_reports_an_empty_list() {
let page = BlocklistEntryPage {
items: Vec::new(),
has_more: false,
next_after: None,
};
let markup = entries_table("/admin", "token", &page).into_string();
assert!(markup.contains("No domains or patterns are blocked"));
}
}
@@ -764,56 +764,6 @@ mod tests {
serde_json::from_value(value).expect("valid admin user")
}
fn entry(log_id: &str, action: &str, reason: &str, metadata: Value) -> AuditLogEntry {
serde_json::from_value(json!({
"log_id": log_id,
"admin_user_id": "1400000000000000001",
"admin_user": {"id": "1400000000000000001", "username": "lilith", "discriminator": "0001", "global_name": null},
"action": action,
"target_id": "1500000000000000001",
"target_type": "user",
"audit_log_reason": reason,
"metadata": metadata,
"created_at": "2026-09-01T10:00:00.000Z"
}))
.expect("valid audit log entry")
}
#[test]
fn pending_deletion_card_names_the_scheduler_and_the_deletion_it_cancels() {
let target = user(json!({
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
"deletion_reason_code": 3,
"deletion_public_reason": "Spam",
"deletion_audit_log_reason": "Report batch 12",
"deletion_scheduled_by": "1400000000000000001",
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
}));
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
let markup =
deletion_card("/admin", &target, "csrf", Some(&scheduler), false).into_string();
assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#));
assert!(markup.contains("lilith"));
assert!(markup.contains("Report batch 12"));
assert!(
markup.contains(
r#"name="expected_pending_deletion_at" value="2026-10-30T17:40:29.690Z""#
)
);
assert!(markup.contains(r#"name="notify_user" value="true""#));
assert!(!markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains("Cancel lilith's deletion (Spam, due"));
assert!(markup.contains(r#"name="private_reason" required"#));
}
#[test]
fn schedule_form_makes_the_reason_an_explicit_choice() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"<option value="" disabled selected>Choose a reason</option>"#));
assert!(!markup.contains(r#"<option value="1" selected>"#));
assert!(!markup.contains("replace_pending_deletion_at"));
}
#[test]
fn schedule_form_emails_the_user_by_default() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
@@ -864,49 +814,4 @@ mod tests {
));
assert!(markup.contains(r#"name="private_reason""#));
}
#[test]
fn current_ban_is_the_entry_matching_the_ban_end_and_notes_attach_to_it() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let logs = vec![
entry(
"3",
"annotate_ban",
"Also sent links",
json!({"ban_audit_log_id": "2"}),
),
entry(
"2",
"temp_ban",
"Regel § 3",
json!({"banned_until": "2026-10-01T00:00:00.000Z"}),
),
entry(
"1",
"temp_ban",
"Older ban",
json!({"banned_until": "2026-01-01T00:00:00.000Z"}),
),
entry(
"4",
"annotate_ban",
"Old note",
json!({"ban_audit_log_id": "1"}),
),
];
let ban = find_current_ban(&target, &logs).expect("current ban");
assert_eq!(ban.entry.log_id, "2");
assert_eq!(
ban.notes
.iter()
.map(|note| note.log_id.as_str())
.collect::<Vec<_>>(),
["3"]
);
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban), false).into_string();
assert!(markup.contains("Regel § 3"));
assert!(markup.contains("Also sent links"));
assert!(markup.contains(r#"name="ban_audit_log_id" value="2""#));
assert!(markup.contains("?action=annotate_ban&amp;tab=moderation"));
}
}
@@ -584,84 +584,3 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> AdminConfig {
AdminConfig {
env: crate::config::RuntimeEnv::Test,
host: String::new(),
port: 3020,
secret_key_base: "test-secret".to_owned(),
base_path: "/admin".to_owned(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: true,
proxy: crate::config::ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
fn render_email_row(username_sign_in: bool) -> String {
let user: AdminUser = serde_json::from_value(serde_json::json!({
"id": "1500000000000000001",
"username": "target",
"discriminator": "0001",
"email": "[email protected]"
}))
.expect("valid admin user");
let acls = vec![acl::USER_VIEW_EMAIL.to_owned()];
render_overview_tab(
&test_config(),
&user,
&acls,
"csrf",
None,
None,
false,
username_sign_in,
)
.into_string()
}
#[test]
fn username_mode_hides_the_email_row() {
assert!(!render_email_row(true).contains("[email protected]"));
}
#[test]
fn email_mode_shows_the_email_row() {
assert!(render_email_row(false).contains("[email protected]"));
}
#[test]
fn last_active_and_grace_end_use_the_panel_date_format() {
let user: AdminUser = serde_json::from_value(serde_json::json!({
"id": "1500000000000000001",
"username": "target",
"discriminator": "0001",
"last_active_at": "2026-10-06T20:23:38.591Z",
"premium_grace_ends_at": "2026-11-01T08:00:00Z"
}))
.expect("valid admin user");
let html =
render_overview_tab(&test_config(), &user, &[], "csrf", None, None, false, false)
.into_string();
assert!(!html.contains("2026-10-06T20:23"));
assert!(!html.contains("2026-11-01T08:00"));
assert!(html.contains(&format_admin_timestamp("2026-10-06T20:23:38.591Z")));
assert!(html.contains(&format_admin_timestamp("2026-11-01T08:00:00Z")));
}
}
@@ -375,168 +375,3 @@ fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry) -> Markup {
}
html! {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ProxyConfig, RuntimeEnv};
use serde_json::json;
fn test_config() -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: String::new(),
port: 0,
secret_key_base: "test-secret".to_owned(),
base_path: "/admin".to_owned(),
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
#[test]
fn sent_webhook_reports_name_the_webhook_instead_of_the_guild() {
let report: ReportEntry = serde_json::from_value(json!({
"report_id": "1800000000000000004",
"reporter_id": "1500000000000000000",
"reported_at": "2026-10-04T10:00:00.000Z",
"status": 0,
"report_type": 0,
"category": "spam",
"reported_user_id": null,
"reported_webhook_id": "1700000000000000500",
"reported_webhook_name": "Harbor Bulletin",
"reported_guild_id": "1700000000000000800",
"reported_guild_name": "Harbor"
}))
.expect("valid report");
let config = test_config();
let sent = report_row(&config, "/admin", "sent", &report).into_string();
assert!(
sent.contains(r#"href="/admin/reports?reported_webhook_id=1700000000000000500""#),
"{sent}"
);
assert!(
sent.contains(r#"data-report-webhook="1700000000000000500""#),
"{sent}"
);
assert!(sent.contains("Harbor Bulletin"), "{sent}");
assert!(!sent.contains("/admin/guilds/"), "{sent}");
let received = report_row(&config, "/admin", "received", &report).into_string();
assert!(!received.contains("data-report-webhook"), "{received}");
assert!(
received.contains(r#"href="/admin/users/1500000000000000000""#),
"{received}"
);
}
#[test]
fn report_rows_label_the_category_and_keep_the_key() {
let config = test_config();
let report: ReportEntry = serde_json::from_value(json!({
"report_id": "1800000000000000005",
"reporter_id": "1500000000000000000",
"reported_at": "2026-10-04T10:00:00.000Z",
"status": 0,
"report_type": 1,
"category": "inappropriate_profile",
"reported_user_id": "1500000000000000001",
"reason": "harassment",
"reason_label": "Harassment or bullying"
}))
.expect("valid report");
for kind in ["sent", "received"] {
let markup = report_row(&config, "/admin", kind, &report).into_string();
assert!(
markup.contains(r#"data-report-category="inappropriate_profile""#),
"{markup}"
);
assert!(markup.contains(">Inappropriate profile<"), "{markup}");
assert!(!markup.contains(">inappropriate_profile<"), "{markup}");
assert!(
markup.contains(r#"data-report-reason="harassment""#),
"{markup}"
);
assert!(markup.contains("Oct 4, 2026, 10:00 AM UTC"), "{markup}");
assert!(!markup.contains("2026-10-04T10:00:00.000Z"), "{markup}");
assert_eq!(
markup
.matches(r#"href="/admin/reports/1800000000000000005""#)
.count(),
1,
"{markup}"
);
assert!(!markup.contains(">View<"), "{markup}");
}
let mut unknown = report.clone();
unknown.category = Some("future_value".to_owned());
let markup = report_row(&config, "/admin", "sent", &unknown).into_string();
assert!(markup.contains(">future_value<"), "{markup}");
}
#[test]
fn report_rows_skip_a_reason_that_repeats_the_category() {
let config = test_config();
let report: ReportEntry = serde_json::from_value(json!({
"report_id": "1800000000000000007",
"reporter_id": "1500000000000000000",
"reporter_username": "reporter_0423a7212d56",
"reporter_discriminator": "8650",
"reporter_global_name": "Avery Reporter",
"reported_at": "2026-10-04T10:00:00.000Z",
"status": 0,
"report_type": 0,
"category": "harassment",
"reported_user_id": "1500000000000000001",
"reason": "harassment",
"reason_label": "Harassment or bullying"
}))
.expect("valid report");
let markup = report_row(&config, "/admin", "received", &report).into_string();
assert_eq!(
markup.matches("Harassment or bullying").count(),
2,
"{markup}"
);
assert_eq!(
markup.matches(r#"data-report-reason="harassment""#).count(),
2,
"{markup}"
);
assert!(
markup.contains(
r#"<div class="mt-1 xl:hidden" data-report-type-compact="1800000000000000007">"#
),
"{markup}"
);
assert!(
markup.contains(
r#"<td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell">"#
),
"{markup}"
);
assert!(
markup.contains(r#"data-report-category="harassment""#),
"{markup}"
);
assert!(markup.contains(">Message<"), "{markup}");
assert!(
markup.contains(r#"Avery Reporter <span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
"{markup}"
);
assert!(markup.contains(">Pending<"), "{markup}");
}
}
@@ -459,25 +459,3 @@ fn users_url(base: &str, params: &UserListParams, page: u32) -> String {
pairs.push(format!("page={page}"));
format!("{base}/users?{}", pairs.join("&"))
}
#[cfg(test)]
mod tests {
use super::*;
fn params() -> UserListParams {
UserListParams::from_query(None, None, None, None, None, None)
}
#[test]
fn username_mode_has_no_email_search() {
let markup = search_form("/admin", &params(), false).into_string();
assert!(!markup.contains("search-email"));
assert!(markup.contains("search-q"));
}
#[test]
fn email_mode_keeps_the_email_search() {
let markup = search_form("/admin", &params(), true).into_string();
assert!(markup.contains("search-email"));
}
}
-24
View File
@@ -4,27 +4,3 @@ pub fn format_discriminator(discriminator: &str) -> String {
let num: u16 = discriminator.parse().unwrap_or(0);
format!("{num:04}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_discriminator_pads_short() {
assert_eq!(format_discriminator("1"), "0001");
assert_eq!(format_discriminator("42"), "0042");
assert_eq!(format_discriminator("0"), "0000");
}
#[test]
fn format_discriminator_four_digits() {
assert_eq!(format_discriminator("1234"), "1234");
assert_eq!(format_discriminator("9999"), "9999");
}
#[test]
fn format_discriminator_invalid_input() {
assert_eq!(format_discriminator(""), "0000");
assert_eq!(format_discriminator("abc"), "0000");
}
}
-70
View File
@@ -128,73 +128,3 @@ impl MultiValueForm {
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_string_trims_whitespace() {
assert_eq!(clean_string(" hello "), Some("hello".to_owned()));
}
#[test]
fn clean_string_returns_none_for_empty() {
assert_eq!(clean_string(""), None);
assert_eq!(clean_string(" "), None);
assert_eq!(clean_string("\t\n"), None);
}
#[test]
fn clean_string_preserves_inner_spaces() {
assert_eq!(
clean_string(" hello world "),
Some("hello world".to_owned())
);
}
#[test]
fn parse_comma_separated_splits() {
assert_eq!(parse_comma_separated("a, b, c"), vec!["a", "b", "c"]);
assert_eq!(parse_comma_separated("a\nb\r\nc"), vec!["a", "b", "c"]);
}
#[test]
fn parse_comma_separated_filters_empty() {
assert_eq!(parse_comma_separated("a,,b, ,c"), vec!["a", "b", "c"]);
assert!(parse_comma_separated("").is_empty());
assert!(parse_comma_separated(", , ,").is_empty());
}
#[test]
fn multi_value_form_preserves_repeated_fields() {
let form = MultiValueForm::parse(b"fields%5B%5D=avatar&fields%5B%5D=banner&name=Test");
assert_eq!(
form.list_values("fields[]"),
vec!["avatar".to_owned(), "banner".to_owned()]
);
assert_eq!(form.clean("name"), Some("Test".to_owned()));
}
#[test]
fn multi_value_form_list_values_accepts_comma_and_repeated_values() {
let form = MultiValueForm::parse(b"acls=user.read,user.write&acls=guild.read");
assert_eq!(
form.list_values("acls"),
vec![
"user.read".to_owned(),
"user.write".to_owned(),
"guild.read".to_owned()
]
);
}
#[test]
fn multi_value_form_bool_value_accepts_html_checkbox_values() {
assert!(MultiValueForm::parse(b"enabled=on").bool_value("enabled"));
assert!(MultiValueForm::parse(b"enabled=true").bool_value("enabled"));
assert!(!MultiValueForm::parse(b"enabled=false").bool_value("enabled"));
}
}
-14
View File
@@ -11,17 +11,3 @@ pub fn count_noun(count: u64, singular: &str, plural: &str) -> String {
pub fn noun_for(count: u64, singular: &'static str, plural: &'static str) -> &'static str {
if count == 1 { singular } else { plural }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn count_noun_picks_the_singular_only_for_one() {
assert_eq!(count_noun(0, "result", "results"), "0 results");
assert_eq!(count_noun(1, "result", "results"), "1 result");
assert_eq!(count_noun(2, "entry", "entries"), "2 entries");
assert_eq!(noun_for(1, "item", "items"), "item");
assert_eq!(noun_for(3, "item", "items"), "items");
}
}
-49
View File
@@ -54,52 +54,3 @@ pub fn snowflake_creation_date(snowflake: &str) -> String {
})
.map_or_else(|| "Unknown".to_owned(), format_admin_datetime)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snowflake_to_timestamp_ms_known_value() {
assert_eq!(snowflake_to_timestamp_ms("0"), Some(FLUXER_EPOCH));
}
#[test]
fn snowflake_to_timestamp_ms_real_id() {
let snowflake = (1u64 << 22).to_string();
assert_eq!(
snowflake_to_timestamp_ms(&snowflake),
Some(FLUXER_EPOCH + 1)
);
}
#[test]
fn snowflake_to_timestamp_ms_invalid() {
assert_eq!(snowflake_to_timestamp_ms(""), None);
assert_eq!(snowflake_to_timestamp_ms("abc"), None);
assert_eq!(snowflake_to_timestamp_ms("-1"), None);
}
#[test]
fn snowflake_creation_date_invalid() {
assert_eq!(snowflake_creation_date("abc"), "Unknown");
}
#[test]
fn snowflake_creation_date_valid() {
assert_eq!(snowflake_creation_date("0"), "Jan 1, 2015, 12:00 AM UTC");
}
#[test]
fn admin_timestamps_render_in_the_panel_format() {
assert_eq!(
format_admin_timestamp("2026-10-06T14:05:09.123Z"),
"Oct 6, 2026, 2:05 PM UTC"
);
assert_eq!(
format_admin_timestamp("2026-10-06T00:30:00+02:00"),
"Oct 5, 2026, 10:30 PM UTC"
);
assert_eq!(format_admin_timestamp("not a date"), "not a date");
}
}
-42
View File
@@ -10,10 +10,6 @@ pub async fn with_unique_usernames<F: Future>(unique_usernames: bool, future: F)
UNIQUE_USERNAMES.scope(unique_usernames, future).await
}
pub fn sync_with_unique_usernames<R>(unique_usernames: bool, f: impl FnOnce() -> R) -> R {
UNIQUE_USERNAMES.sync_scope(unique_usernames, f)
}
pub fn unique_usernames() -> bool {
UNIQUE_USERNAMES.try_with(|value| *value).unwrap_or(false)
}
@@ -29,41 +25,3 @@ pub fn user_tag(username: &str, discriminator: &str, is_bot: bool) -> String {
username.to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn email_mode_keeps_every_tag() {
assert_eq!(user_tag("alice", "0000", false), "alice#0000");
assert_eq!(user_tag("alice", "0042", false), "alice#0042");
sync_with_unique_usernames(false, || {
assert_eq!(user_tag("alice", "0000", false), "alice#0000");
assert_eq!(user_tag("bot", "0000", true), "bot#0000");
});
}
#[test]
fn username_mode_hides_zero_tag_for_humans() {
sync_with_unique_usernames(true, || {
assert_eq!(user_tag("alice", "0000", false), "alice");
assert_eq!(user_tag("alice", "0", false), "alice");
assert!(!shows_discriminator("0000", false));
});
}
#[test]
fn username_mode_keeps_bot_and_non_zero_tags() {
sync_with_unique_usernames(true, || {
assert_eq!(user_tag("helper", "4363", true), "helper#4363");
assert_eq!(user_tag("helper", "0000", true), "helper#0000");
assert_eq!(user_tag("legacy", "0042", false), "legacy#0042");
});
}
#[test]
fn mode_defaults_to_email_outside_a_request() {
assert!(!unique_usernames());
}
}
+1 -2
View File
@@ -10,7 +10,7 @@ use axum::{
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
config::{AdminConfig, ProxyConfig},
session,
};
use serde_json::{Value, json};
@@ -85,7 +85,6 @@ async fn setup() -> TestApp {
fn production_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Production,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -11,7 +11,7 @@ use axum::{
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
config::{AdminConfig, ProxyConfig},
session,
};
use serde_json::{Value, json};
@@ -346,7 +346,6 @@ fn admin_user() -> Value {
fn test_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
-635
View File
@@ -1,635 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[path = "parity/mod.rs"]
mod parity_support;
use axum::{
Json, Router,
extract::{Path, RawQuery},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use fluxer_admin::api::{
generated::types::{
AdminReportListResponse, LookupGuildResponse, ReportAdminResponseSchema,
SearchGuildsResponse,
},
types::{ReportEntry, SearchReportsResponse},
};
use parity_support::{
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
html_normalizer, rust_server,
};
use serde_json::{Value, json};
use std::{error::Error, io};
use tokio::net::TcpListener;
const SEARCH_REPORTS_V2: &str = include_str!("parity/fixtures/api/search_reports_v2.json");
const SEARCH_REPORTS_WEBHOOK: &str =
include_str!("parity/fixtures/api/search_reports_webhook.json");
const REPORT_WEBHOOK_DETAIL: &str = include_str!("parity/fixtures/api/report_webhook_detail.json");
const REPORT_EVIDENCE_DETAIL: &str =
include_str!("parity/fixtures/api/report_evidence_detail.json");
const EVIDENCE_REPORT_ID: &str = "1556352159220498613";
const EVIDENCE_BOT_ID: &str = "1556352159149195428";
const EVIDENCE_WEBHOOK_ID: &str = "1556352159132418208";
const EVIDENCE_DELETED_AUTHOR_ID: &str = "1556352159216304308";
const WEBHOOK_ID: &str = "1556114449176200401";
const WEBHOOK_REPORT_ID: &str = "1556114449264280806";
const BOT_REPORT_ID: &str = "1556114449268475111";
const BOT_USER_ID: &str = "1556114449192977621";
const WEBHOOK_CREATOR_ID: &str = "1556114449125868741";
#[test]
fn html_normalizer_canonicalizes_attribute_order_and_csrf_values() {
let left = r#"<form><input value="aaaaaaaa" name="_csrf" type="hidden"><svg><line x1="1" x2="2"></line></svg><a class="b" href="/static/app.css?v=123" id="x">Open</a></form>"#;
let right = r#"<form><input type="hidden" name="_csrf" value="bbbbbbbb"/><svg><line x2="2" x1="1"/></svg><a id="x" href="/static/app.css?v=456" class="b">Open</a></form>"#;
assert_eq!(
html_normalizer::normalize_html(left),
html_normalizer::normalize_html(right)
);
}
#[test]
fn text_normalizer_replaces_ports_assets_query_values_and_cookie_tokens() {
let raw = "http://127.0.0.1:31987/auth/start?state=abc&next=/static/app.css?v=dev admin_session=abcdef; csrf_token=12345; oauth_state=zz";
let normalized = html_normalizer::normalize_text(raw);
assert!(normalized.contains("127.0.0.1:__PORT__"));
assert!(normalized.contains("state=__OAUTH_STATE__"));
assert!(normalized.contains("/static/app.css"));
assert!(normalized.contains("admin_session=__SESSION__"));
assert!(normalized.contains("csrf_token=__CSRF_COOKIE__"));
assert!(normalized.contains("oauth_state=__OAUTH_STATE__"));
}
#[test]
fn text_normalizer_replaces_script_csrf_values() {
let raw = r#"<script>(function(){var csrf="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";})()</script>"#;
let normalized = html_normalizer::normalize_text(raw);
assert!(normalized.contains(r#"var csrf="__CSRF_TOKEN__""#));
}
#[test]
fn html_normalizer_allows_intentional_rust_markup_fixes() {
let ts = r#"<html><head></head><body><div class="flex flex-col gap-8 items-stretch"></div><div class="flex flex-col gap-4 items-stretch"></div><script defer>window.__fluxerDrawerInit = true;</script><aside data-drawer-panel="user-peek" aria-hidden="true"></aside></body></html>"#;
let rust = r#"<!DOCTYPE html><html><head><script src="/static/htmx.min.js?t=parity" defer></script></head><body hx-boost="true"><div class="flex flex-col gap-8 items-center"></div><div id="users-results" class="flex flex-col gap-4 items-stretch"></div><script defer>document.body.addEventListener('showFlash', function () {});</script><script defer>window.__adminCopyToClipboard = function () {};</script><aside id="user-peek" data-drawer-panel="user-peek" popover="auto"></aside></body></html>"#;
assert_eq!(
html_normalizer::normalize_html(ts),
html_normalizer::normalize_html(rust)
);
}
#[test]
fn guild_search_fixture_matches_the_generated_response_contract() {
let response: SearchGuildsResponse =
serde_json::from_str(include_str!("parity/fixtures/api/search_guilds.json"))
.expect("guild search fixture must match the generated response contract");
assert_eq!(response.guilds.len(), 1);
let guild = &response.guilds[0];
assert_eq!(guild.name, "Parity Guild");
assert_eq!(guild.nsfw, Some(false));
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
}
#[test]
fn guild_lookup_fixture_matches_the_generated_response_contract() {
let response: LookupGuildResponse =
serde_json::from_str(include_str!("parity/fixtures/api/lookup_guild.json"))
.expect("guild lookup fixture must match the generated response contract");
let guild = response
.guild
.expect("guild lookup fixture must contain a guild");
assert_eq!(String::from(guild.name), "Parity Guild");
assert_eq!(guild.nsfw, Some(false));
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
assert_eq!(guild.channels.len(), 1);
assert_eq!(guild.channels[0].content_warning_level.as_deref(), Some(&0));
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<(), Box<dyn Error>> {
let api_server = api_fixtures::ApiFixtureServer::start_default()
.await
.map_err(test_error)?;
let rust_admin = rust_server::start(api_server.base_url())
.await
.map_err(test_error)?;
let client = capture::capture_client().map_err(test_error)?;
let session = fluxer_admin::session::create_session(
TEST_ADMIN_USER_ID,
TEST_ACCESS_TOKEN,
TEST_ADMIN_SECRET,
);
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
let cases = [
("/dashboard", 302, Some("/users"), None),
("/users?q=Parity", 200, None, Some("Parity User")),
("/guilds?q=Parity", 200, None, Some("Parity Guild")),
(
"/guilds/1600000000000000001",
200,
None,
Some("Parity Guild"),
),
("/reports", 200, None, Some("1700000000000000001")),
(
"/reports/1700000000000000001",
200,
None,
Some("Report Details"),
),
];
for (route, expected_status, expected_location, expected_body) in cases {
let response =
capture::fetch_route(&client, rust_admin.base_url(), route, Some(&session_cookie))
.await
.map_err(test_error)?;
assert_eq!(response.status, expected_status, "{route}: {response:#?}");
if let Some(expected_location) = expected_location {
assert_eq!(
response.location.as_deref(),
Some(expected_location),
"{route}: {response:#?}"
);
}
if let Some(expected_body) = expected_body {
assert!(
response.body.contains(expected_body),
"{route}: expected body to contain {expected_body:?}\n{response:#?}"
);
}
}
Ok(())
}
#[test]
fn report_search_v2_fixture_matches_the_generated_and_hand_written_contracts() {
let generated: AdminReportListResponse = serde_json::from_str(SEARCH_REPORTS_V2)
.expect("v2 report search fixture must match the generated response contract");
let flow = generated.reports[0]
.flow
.as_ref()
.expect("the v2 message report has answers");
assert_eq!(flow.surface, "in_app");
assert_eq!(flow.steps.len(), 4);
assert_eq!(generated.reports[0].reason.as_deref(), Some("csam"));
assert_eq!(generated.reports[2].reason, None);
let response: SearchReportsResponse = serde_json::from_str(SEARCH_REPORTS_V2)
.expect("v2 report search fixture must match the hand-written response type");
assert_eq!(response.total, 3);
let [message, user, legacy] = response.reports.as_slice() else {
panic!("expected three reports");
};
assert_eq!(
message.reason_label.as_deref(),
Some("Child sexual abuse material")
);
assert_eq!(message.reason_highest_priority, Some(true));
assert_eq!(message.category.as_deref(), Some("child_safety"));
let user_flow = user.flow.as_ref().expect("the v2 user report has answers");
assert_eq!(user.reason.as_deref(), Some("harassment"));
assert_eq!(user_flow.locale.as_deref(), Some("de"));
assert_eq!(user_flow.steps[0].screen_id, "profile_intro");
assert!(user_flow.steps[0].option_id.is_none());
assert!(user_flow.steps[0].items.is_empty());
assert_eq!(
user_flow.steps[1]
.items
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
["photo", "profile_text"]
);
assert!(legacy.reason.is_none());
assert!(legacy.reason_label.is_none());
assert!(legacy.flow.is_none());
assert!(legacy.reporter_good_faith_confirmed.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_admin_renders_v2_report_fixture_with_reason_and_answers() -> Result<(), Box<dyn Error>>
{
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
let rust_admin = rust_server::start(&api_endpoint)
.await
.map_err(test_error)?;
let client = capture::capture_client().map_err(test_error)?;
let session = fluxer_admin::session::create_session(
TEST_ADMIN_USER_ID,
TEST_ACCESS_TOKEN,
TEST_ADMIN_SECRET,
);
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
let list = capture::fetch_route(
&client,
rust_admin.base_url(),
"/reports?reason=csam",
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(list.status, 200, "{list:#?}");
assert!(list.body.contains(r#"name="reason""#), "{}", list.body);
assert!(
list.body.contains("Priority: Child sexual abuse material"),
"{}",
list.body
);
assert!(
list.body.contains(r#"data-report-reason="csam""#),
"{}",
list.body
);
assert!(
!list.body.contains(r#"data-report-reason="harassment""#),
"{}",
list.body
);
let detail = capture::fetch_route(
&client,
rust_admin.base_url(),
"/reports/1556008115705480394",
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(detail.status, 200, "{detail:#?}");
for expected in [
"Report Answers",
"Report profile",
"Which parts of their profile are a problem?: Pictures, Profile text",
"Shown to the reporter in de",
"Surface: In app",
"Harassment or bullying",
] {
assert!(
detail.body.contains(expected),
"{expected}\n{}",
detail.body
);
}
let legacy = capture::fetch_route(
&client,
rust_admin.base_url(),
"/reports/1556008115709674699",
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(legacy.status, 200, "{legacy:#?}");
assert!(!legacy.body.contains("Report Answers"), "{}", legacy.body);
assert!(
!legacy.body.contains("data-report-reason"),
"{}",
legacy.body
);
Ok(())
}
#[test]
fn report_webhook_fixtures_match_the_generated_and_hand_written_contracts() {
let generated: AdminReportListResponse = serde_json::from_str(SEARCH_REPORTS_WEBHOOK)
.expect("webhook report search fixture must match the generated response contract");
assert_eq!(generated.reports.len(), 2);
let response: SearchReportsResponse = serde_json::from_str(SEARCH_REPORTS_WEBHOOK)
.expect("webhook report search fixture must match the hand-written response type");
let [webhook, bot] = response.reports.as_slice() else {
panic!("expected two reports");
};
assert_eq!(webhook.report_id, WEBHOOK_REPORT_ID);
assert!(webhook.reported_user_id.is_none());
assert_eq!(webhook.reported_webhook_id.as_deref(), Some(WEBHOOK_ID));
assert_eq!(
webhook.reported_webhook_name.as_deref(),
Some("Harbor Bulletin")
);
assert_eq!(bot.reported_user_id.as_deref(), Some(BOT_USER_ID));
assert!(bot.reported_webhook_id.is_none());
let generated_detail: ReportAdminResponseSchema = serde_json::from_str(REPORT_WEBHOOK_DETAIL)
.expect("webhook report detail fixture must match the generated response contract");
let via_generated: ReportEntry = serde_json::from_value(
serde_json::to_value(generated_detail).expect("serialize generated detail"),
)
.expect("hand-written report type from the generated detail");
let direct: ReportEntry = serde_json::from_str(REPORT_WEBHOOK_DETAIL)
.expect("webhook report detail fixture must match the hand-written report type");
for detail in [&via_generated, &direct] {
assert!(detail.reported_user_id.is_none());
assert_eq!(detail.reported_webhook_id.as_deref(), Some(WEBHOOK_ID));
assert_eq!(
detail.reported_webhook_creator_id.as_deref(),
Some(WEBHOOK_CREATOR_ID)
);
assert_eq!(
detail.reported_webhook_creator_global_name.as_deref(),
Some("Morgan Owner")
);
assert_eq!(detail.reported_webhook_type, Some(1));
assert_eq!(
detail.reported_webhook_channel_id,
detail.reported_channel_id
);
assert_eq!(detail.reported_webhook_guild_id, detail.reported_guild_id);
assert_eq!(
detail.reported_webhook_created_at.as_deref(),
Some("2026-10-04T01:23:15.892Z")
);
assert!(detail.reported_webhook_application_id.is_none());
let context = detail.message_context.as_ref().expect("message context");
let entry = context
.iter()
.find(|entry| entry["webhook_id"].as_str() == Some(WEBHOOK_ID))
.expect("the webhook message is in the context");
assert_eq!(entry["author_id"].as_str(), Some(WEBHOOK_ID));
assert_eq!(entry["author_username"].as_str(), Some("Harbor Bulletin"));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_admin_renders_webhook_report_fixture() -> Result<(), Box<dyn Error>> {
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
let rust_admin = rust_server::start(&api_endpoint)
.await
.map_err(test_error)?;
let client = capture::capture_client().map_err(test_error)?;
let session = fluxer_admin::session::create_session(
TEST_ADMIN_USER_ID,
TEST_ACCESS_TOKEN,
TEST_ADMIN_SECRET,
);
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
let webhook_marker = format!(r#"data-report-webhook="{WEBHOOK_ID}""#);
let webhook_user_link = format!("/users/{WEBHOOK_ID}");
let list = capture::fetch_route(
&client,
rust_admin.base_url(),
&format!("/reports?reported_webhook_id={WEBHOOK_ID}"),
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(list.status, 200, "{list:#?}");
assert!(list.body.contains(WEBHOOK_REPORT_ID), "{}", list.body);
assert!(!list.body.contains(BOT_REPORT_ID), "{}", list.body);
assert!(list.body.contains(&webhook_marker), "{}", list.body);
assert!(list.body.contains("Harbor Bulletin"), "{}", list.body);
assert!(list.body.contains("Channel: general"), "{}", list.body);
assert!(!list.body.contains(&webhook_user_link), "{}", list.body);
let both = capture::fetch_route(
&client,
rust_admin.base_url(),
"/reports?reporter_id=1556114449113285826",
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(both.status, 200, "{both:#?}");
assert!(
both.body
.contains(&format!(r#"href="/users/{BOT_USER_ID}""#)),
"{}",
both.body
);
let detail = capture::fetch_route(
&client,
rust_admin.base_url(),
&format!("/reports/{WEBHOOK_REPORT_ID}"),
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(detail.status, 200, "{detail:#?}");
for expected in [
webhook_marker.clone(),
">Webhook ID<".to_owned(),
format!(r#"href="/reports?reported_webhook_id={WEBHOOK_ID}""#),
"Harbor Commons".to_owned(),
"Message Context".to_owned(),
format!(r#"data-message-webhook="{WEBHOOK_ID}""#),
format!(r#"href="/users/{BOT_USER_ID}""#),
">Webhook Creator<".to_owned(),
format!(r#"data-report-webhook-creator="{WEBHOOK_CREATOR_ID}""#),
format!(r#"href="/users/{WEBHOOK_CREATOR_ID}""#),
"Morgan Owner".to_owned(),
">Webhook Type<".to_owned(),
">Incoming<".to_owned(),
">Webhook Created<".to_owned(),
"Oct 4, 2026, 1:23 AM UTC".to_owned(),
] {
assert!(
detail.body.contains(&expected),
"{expected}\n{}",
detail.body
);
}
assert!(!detail.body.contains(&webhook_user_link), "{}", detail.body);
assert!(
!detail.body.contains("View Reported User"),
"{}",
detail.body
);
for absent in [
">Webhook Channel ID<",
">Webhook Guild ID<",
">Webhook Record<",
"data-report-webhook-creator-deleted",
] {
assert!(!detail.body.contains(absent), "{absent}\n{}", detail.body);
}
Ok(())
}
#[test]
fn report_evidence_fixture_matches_the_generated_and_hand_written_contracts() {
let generated: ReportAdminResponseSchema = serde_json::from_str(REPORT_EVIDENCE_DETAIL)
.expect("evidence report detail fixture must match the generated response contract");
let via_generated: ReportEntry = serde_json::from_value(
serde_json::to_value(generated).expect("serialize generated detail"),
)
.expect("hand-written report type from the generated detail");
let direct: ReportEntry = serde_json::from_str(REPORT_EVIDENCE_DETAIL)
.expect("evidence report detail fixture must match the hand-written report type");
for detail in [&via_generated, &direct] {
assert_eq!(detail.reported_user_id.as_deref(), Some(EVIDENCE_BOT_ID));
assert_eq!(detail.reported_user_bot, Some(true));
let snapshot = detail
.reported_profile_snapshot
.as_ref()
.expect("the bot author has a profile snapshot");
let user = snapshot.user.as_ref().expect("user snapshot");
assert_eq!(user.id, EVIDENCE_BOT_ID);
assert_eq!(user.username.as_deref(), Some("Harbor_Helper"));
let member = snapshot.member.as_ref().expect("member snapshot");
assert_eq!(member.guild_id, "1556352159090475158");
assert!(member.joined_at.is_some());
assert!(snapshot.guild.is_none());
let context = detail.message_context.as_ref().expect("message context");
let author_bot = |author_id: &str| {
context
.iter()
.filter(|entry| entry["author_id"].as_str() == Some(author_id))
.map(|entry| entry["author_bot"].clone())
.collect::<Vec<_>>()
};
assert_eq!(author_bot(EVIDENCE_BOT_ID), [json!(true), json!(true)]);
assert_eq!(author_bot(EVIDENCE_WEBHOOK_ID), [Value::Null]);
assert_eq!(author_bot(EVIDENCE_DELETED_AUTHOR_ID), [Value::Null]);
assert_eq!(author_bot("1556352159069503633"), [json!(false)]);
assert!(context.iter().all(|entry| {
entry["missing_attachments"]
.as_array()
.is_none_or(Vec::is_empty)
}));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_admin_renders_the_evidence_report_fixture() -> Result<(), Box<dyn Error>> {
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
let rust_admin = rust_server::start(&api_endpoint)
.await
.map_err(test_error)?;
let client = capture::capture_client().map_err(test_error)?;
let session = fluxer_admin::session::create_session(
TEST_ADMIN_USER_ID,
TEST_ACCESS_TOKEN,
TEST_ADMIN_SECRET,
);
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
let detail = capture::fetch_route(
&client,
rust_admin.base_url(),
&format!("/reports/{EVIDENCE_REPORT_ID}"),
Some(&session_cookie),
)
.await
.map_err(test_error)?;
assert_eq!(detail.status, 200, "{detail:#?}");
for expected in [
format!(r#"data-report-user-bot="{EVIDENCE_BOT_ID}""#),
format!(r#"data-message-author-bot="{EVIDENCE_BOT_ID}""#),
format!(r#"data-message-webhook="{EVIDENCE_WEBHOOK_ID}""#),
"At Report Time".to_owned(),
"Harbor_Helper#3966".to_owned(),
">Community Profile<".to_owned(),
r#"data-report-legal-hold="none""#.to_owned(),
] {
assert!(
detail.body.contains(&expected),
"{expected}\n{}",
detail.body
);
}
assert_eq!(
detail.body.matches("data-message-author-bot=").count(),
1,
"{}",
detail.body
);
assert!(
!detail.body.contains("data-missing-attachment"),
"{}",
detail.body
);
Ok(())
}
async fn spawn_v2_report_api() -> Result<String, String> {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|error| format!("failed to bind v2 report API: {error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("failed to read v2 report API address: {error}"))?
.port();
let app = Router::new()
.route(
"/admin/users/@me",
get(|| async {
fixture_json(include_str!("parity/fixtures/api/admin_user_me.json"))
}),
)
.route(
"/admin/report-reasons",
get(|| async {
Json(json!({"reasons": [
{"key": "csam", "label": "Child sexual abuse material", "highest_priority": true, "legacy_category_message": "child_safety", "legacy_category_user": "child_safety", "legacy_category_guild": "child_safety"},
{"key": "harassment", "label": "Harassment or bullying", "highest_priority": false, "legacy_category_message": "harassment", "legacy_category_user": "harassment", "legacy_category_guild": "harassment"}
]}))
.into_response()
}),
)
.route("/admin/reports", get(v2_report_search))
.route("/admin/reports/{report_id}", get(v2_report_detail));
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
Ok(format!("http://127.0.0.1:{port}"))
}
fn fixture_json(body: &'static str) -> Response {
let value: Value = serde_json::from_str(body).expect("fixture is JSON");
Json(value).into_response()
}
fn v2_reports() -> Vec<Value> {
[SEARCH_REPORTS_V2, SEARCH_REPORTS_WEBHOOK]
.into_iter()
.flat_map(|body| {
let value: Value = serde_json::from_str(body).expect("fixture is JSON");
value["reports"].as_array().cloned().unwrap_or_default()
})
.collect()
}
async fn v2_report_search(RawQuery(query): RawQuery) -> Response {
let query = query.unwrap_or_default();
let filters = url::form_urlencoded::parse(query.as_bytes())
.filter(|(key, value)| {
matches!(
key.as_ref(),
"reason" | "reporter_id" | "reported_webhook_id"
) && !value.is_empty()
})
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect::<Vec<_>>();
let reports = v2_reports()
.into_iter()
.filter(|report| {
filters
.iter()
.all(|(key, value)| report[key.as_str()].as_str() == Some(value.as_str()))
})
.collect::<Vec<_>>();
Json(json!({"reports": reports, "total": reports.len(), "offset": 0, "limit": 25}))
.into_response()
}
async fn v2_report_detail(Path(report_id): Path<String>) -> Response {
if report_id == WEBHOOK_REPORT_ID {
return fixture_json(REPORT_WEBHOOK_DETAIL);
}
if report_id == EVIDENCE_REPORT_ID {
return fixture_json(REPORT_EVIDENCE_DETAIL);
}
match v2_reports()
.into_iter()
.find(|report| report["report_id"].as_str() == Some(report_id.as_str()))
{
Some(report) => Json(report).into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
fn test_error(message: String) -> Box<dyn Error> {
Box::new(io::Error::other(message))
}
-172
View File
@@ -1,172 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{
Router,
body::Body,
extract::State,
http::{HeaderName, HeaderValue, Request, StatusCode, header},
response::{IntoResponse, Response},
routing::any,
};
use serde::Deserialize;
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
};
use tokio::{net::TcpListener, task::JoinHandle};
#[derive(Clone, Debug)]
struct ApiFixtureRoute {
method: String,
path: String,
status: u16,
content_type: String,
headers: HashMap<String, String>,
body: String,
}
#[derive(Clone)]
struct ApiFixtureSet {
routes: Arc<Vec<ApiFixtureRoute>>,
}
#[derive(Deserialize)]
struct ApiFixtureManifest {
routes: Vec<ApiFixtureManifestRoute>,
}
#[derive(Deserialize)]
struct ApiFixtureManifestRoute {
method: String,
path: String,
status: Option<u16>,
content_type: Option<String>,
headers: Option<HashMap<String, String>>,
body: Option<String>,
body_file: Option<String>,
}
pub struct ApiFixtureServer {
base_url: String,
handle: JoinHandle<()>,
}
impl ApiFixtureServer {
pub async fn start_default() -> Result<Self, String> {
let fixture_set = ApiFixtureSet::from_default_manifest()?;
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|error| format!("failed to bind fixture API server: {error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("failed to read fixture API address: {error}"))?
.port();
let app = Router::new()
.fallback(any(handle_fixture_request))
.with_state(fixture_set.routes);
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
Ok(Self {
base_url: format!("http://127.0.0.1:{port}"),
handle,
})
}
pub fn base_url(&self) -> &str {
&self.base_url
}
}
impl Drop for ApiFixtureServer {
fn drop(&mut self) {
self.handle.abort();
}
}
impl ApiFixtureSet {
fn from_default_manifest() -> Result<Self, String> {
let manifest: ApiFixtureManifest =
serde_json::from_str(include_str!("fixtures/api_routes.json"))
.map_err(|error| format!("failed to parse API fixture manifest: {error}"))?;
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/parity/fixtures/api");
let mut routes = Vec::with_capacity(manifest.routes.len());
for route in manifest.routes {
routes.push(ApiFixtureRoute {
method: route.method.to_ascii_uppercase(),
path: route.path,
status: route.status.unwrap_or(200),
content_type: route
.content_type
.unwrap_or_else(|| "application/json; charset=utf-8".to_owned()),
headers: route.headers.unwrap_or_default(),
body: load_body(&root, route.body, route.body_file)?,
});
}
Ok(Self {
routes: Arc::new(routes),
})
}
}
async fn handle_fixture_request(
State(routes): State<Arc<Vec<ApiFixtureRoute>>>,
request: Request<Body>,
) -> Response {
let method = request.method().as_str();
let path = request.uri().path();
let fixture = routes
.iter()
.find(|route| route.method == method && route.path == path)
.or_else(|| {
routes
.iter()
.find(|route| route.method == "ANY" && route.path == path)
});
match fixture {
Some(fixture) => fixture_response(fixture),
None => (
StatusCode::NOT_FOUND,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json; charset=utf-8"),
)],
format!(r#"{{"error":"missing fixture","method":"{method}","path":"{path}"}}"#),
)
.into_response(),
}
}
fn fixture_response(fixture: &ApiFixtureRoute) -> Response {
let status = StatusCode::from_u16(fixture.status).unwrap_or(StatusCode::OK);
let mut response = (status, fixture.body.clone()).into_response();
if let Ok(value) = HeaderValue::from_str(&fixture.content_type) {
response.headers_mut().insert(header::CONTENT_TYPE, value);
}
for (name, value) in &fixture.headers {
if let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value),
) {
response.headers_mut().insert(name, value);
}
}
response
}
fn load_body(
root: &Path,
body: Option<String>,
body_file: Option<String>,
) -> Result<String, String> {
if let Some(body) = body {
return Ok(body);
}
let Some(body_file) = body_file else {
return Ok(String::new());
};
let path = root.join(body_file);
std::fs::read_to_string(&path)
.map_err(|error| format!("failed to read fixture {}: {error}", path.display()))
}
-57
View File
@@ -1,57 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::html_normalizer;
use reqwest::{Client, redirect::Policy};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NormalizedResponse {
pub status: u16,
pub content_type: Option<String>,
pub location: Option<String>,
pub body: String,
}
pub fn capture_client() -> Result<Client, String> {
Client::builder()
.redirect(Policy::none())
.build()
.map_err(|error| format!("failed to build capture client: {error}"))
}
pub async fn fetch_route(
client: &Client,
base_url: &str,
route: &str,
cookie: Option<&str>,
) -> Result<NormalizedResponse, String> {
let url = format!("{}{}", base_url.trim_end_matches('/'), route);
let mut request = client.get(&url);
if let Some(cookie) = cookie {
request = request.header(reqwest::header::COOKIE, cookie);
}
let response = request
.send()
.await
.map_err(|error| format!("failed to fetch {url}: {error}"))?;
let status = response.status().as_u16();
let headers = response.headers().clone();
let content_type = headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| html_normalizer::normalize_header_value("content-type", value));
let location = headers
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.map(|value| html_normalizer::normalize_header_value("location", value));
let raw_body = response
.text()
.await
.map_err(|error| format!("failed to read body for {url}: {error}"))?;
let body = html_normalizer::normalize_body(content_type.as_deref(), &raw_body);
Ok(NormalizedResponse {
status,
content_type,
location,
body,
})
}
@@ -1,43 +0,0 @@
{
"user": {
"id": "1130650140672000000",
"username": "parity_admin",
"discriminator": 0,
"global_name": "Parity Admin",
"bot": false,
"system": false,
"flags": "0",
"premium_flags": 0,
"avatar": null,
"banner": null,
"bio": "Parity fixture admin user.",
"pronouns": null,
"accent_color": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"date_of_birth": "2000-01-01",
"locale": "en-US",
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": ["*"],
"traits": [],
"has_totp": true,
"authenticator_types": [1],
"last_active_at": "2026-05-26T15:00:00.000Z",
"last_active_ip": "127.0.0.1",
"last_active_ip_reverse": "localhost",
"last_active_location": "Local"
}
}
@@ -1,56 +0,0 @@
{
"guild": {
"id": "1600000000000000001",
"owner_id": "1508576042312688531",
"owner_username": "parity_user",
"owner_global_name": "Parity User",
"owner_discriminator": "4363",
"name": "Parity Guild",
"vanity_url_code": "parity",
"icon": null,
"banner": null,
"splash": null,
"embed_splash": null,
"features": ["COMMUNITY", "DISCOVERABLE"],
"verification_level": 1,
"mfa_level": 0,
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": 0,
"content_warning_text": null,
"explicit_content_filter": 2,
"default_message_notifications": 1,
"afk_channel_id": null,
"afk_timeout": 300,
"system_channel_id": null,
"system_channel_flags": 0,
"rules_channel_id": null,
"disabled_operations": 0,
"member_count": 128,
"channels": [
{
"id": "1600000000000000101",
"name": "general",
"type": 0,
"position": 0,
"parent_id": null,
"nsfw": false,
"nsfw_override": null,
"content_warning_level": 0,
"content_warning_text": null,
"url": null
}
],
"roles": [
{
"id": "1600000000000000001",
"name": "@everyone",
"color": 0,
"position": 0,
"permissions": "0",
"hoist": false,
"mentionable": false
}
]
}
}
@@ -1,45 +0,0 @@
{
"users": [
{
"id": "1508576042312688531",
"username": "parity_user",
"discriminator": 4363,
"global_name": "Parity User",
"bot": false,
"system": false,
"flags": "0",
"premium_flags": 0,
"avatar": null,
"banner": null,
"bio": null,
"pronouns": null,
"accent_color": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"date_of_birth": null,
"locale": "en-US",
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": [],
"traits": [],
"has_totp": false,
"authenticator_types": [],
"last_active_at": "2026-05-26T15:00:00.000Z",
"last_active_ip": "127.0.0.1",
"last_active_ip_reverse": "localhost",
"last_active_location": "Local"
}
]
}
@@ -1,4 +0,0 @@
{
"access_token": "parity-access-token",
"token_type": "Bearer"
}
@@ -1,3 +0,0 @@
{
"id": "1130650140672000000"
}
@@ -1,44 +0,0 @@
{
"report_id": "1700000000000000001",
"reporter_id": "1508576042312688531",
"reporter_tag": "parity_user#4363",
"reporter_username": "parity_user",
"reporter_global_name": "Parity User",
"reporter_discriminator": "4363",
"reporter_email": "[email protected]",
"reporter_full_legal_name": null,
"reporter_country_of_residence": null,
"reported_at": "2026-05-26T15:10:45.134Z",
"status": 0,
"report_type": 0,
"category": "nsfw_violation",
"additional_info": "Parity fixture report.",
"reported_user_id": "1508576042312688531",
"reported_user_tag": "parity_user#4363",
"reported_user_username": "parity_user",
"reported_user_global_name": "Parity User",
"reported_user_discriminator": "4363",
"reported_user_avatar_hash": null,
"reported_guild_id": "1600000000000000001",
"reported_guild_name": "Parity Guild",
"reported_message_id": "1800000000000000001",
"reported_channel_id": "1600000000000000101",
"reported_channel_name": "general",
"reported_channel_nsfw": false,
"reported_guild_invite_code": null,
"reported_guild_nsfw_level": 0,
"reported_guild_nsfw": false,
"reported_guild_content_warning_level": null,
"reported_guild_content_warning_text": null,
"reported_channel_nsfw_override": null,
"reported_channel_content_warning_level": null,
"reported_channel_content_warning_text": null,
"reported_channel_effective_nsfw": false,
"reported_channel_effective_content_warning_level": null,
"reported_channel_effective_content_warning_text": null,
"resolved_at": null,
"resolved_by_admin_id": null,
"public_comment": null,
"mutual_dm_channel_id": null,
"message_context": []
}
@@ -1,614 +0,0 @@
{
"report_id": "1556352159220498613",
"reporter_id": "1556352159077892243",
"reporter_tag": "target_a91da2c28aae#2089",
"reporter_username": "target_a91da2c28aae",
"reporter_global_name": "Jordan Target",
"reporter_discriminator": "2089",
"reporter_email": "[email protected]",
"reporter_full_legal_name": null,
"reporter_country_of_residence": null,
"reported_at": "2026-10-04T17:12:47.653Z",
"status": 0,
"report_type": 0,
"category": "spam",
"additional_info": null,
"reported_user_id": "1556352159149195428",
"reported_user_tag": "Harbor_Helper#3966",
"reported_user_username": "Harbor_Helper",
"reported_user_global_name": null,
"reported_user_discriminator": "3966",
"reported_user_avatar_hash": null,
"reported_user_bot": true,
"reported_webhook_id": null,
"reported_webhook_name": null,
"reported_webhook_avatar_hash": null,
"reported_webhook_default_name": null,
"reported_webhook_default_avatar_hash": null,
"reported_webhook_type": null,
"reported_webhook_application_id": null,
"reported_webhook_channel_id": null,
"reported_webhook_guild_id": null,
"reported_webhook_created_at": null,
"reported_webhook_creator_id": null,
"reported_webhook_creator_tag": null,
"reported_webhook_creator_username": null,
"reported_webhook_creator_global_name": null,
"reported_webhook_creator_discriminator": null,
"reported_webhook_creator_avatar_hash": null,
"reported_guild_id": "1556352159090475158",
"reported_guild_name": "Harbor Commons",
"reported_guild_icon_hash": null,
"reported_message_id": "1556352159161778343",
"reported_channel_id": "1556352159103058073",
"reported_channel_name": "general",
"reported_channel_nsfw": false,
"reported_guild_invite_code": null,
"reported_guild_nsfw_level": 0,
"reported_guild_nsfw": false,
"reported_guild_content_warning_level": 0,
"reported_guild_content_warning_text": null,
"reported_channel_nsfw_override": null,
"reported_channel_content_warning_level": 0,
"reported_channel_content_warning_text": null,
"reported_channel_effective_nsfw": false,
"reported_channel_effective_content_warning_level": 0,
"reported_channel_effective_content_warning_text": null,
"resolved_at": null,
"resolved_by_admin_id": null,
"public_comment": null,
"mutual_dm_channel_id": null,
"message_context": [
{
"id": "1556352159115640988",
"channel_id": "1556352159103058073",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556352159090475158",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "",
"timestamp": "2026-10-04T17:07:50.358Z",
"attachments": [],
"author_id": "1556352159069503633",
"author_username": "reporter_c03a2ee3db86",
"author_global_name": null,
"author_discriminator": "2642",
"author_avatar": null,
"webhook_id": null,
"author_bot": false,
"missing_attachments": []
},
{
"id": "1556352159119835293",
"channel_id": "1556352159103058073",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556352159090475158",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "",
"timestamp": "2026-10-04T17:07:50.359Z",
"attachments": [],
"author_id": "1556352159073697938",
"author_username": "unverified_7d9456caf467",
"author_global_name": null,
"author_discriminator": "4822",
"author_avatar": null,
"webhook_id": null,
"author_bot": false,
"missing_attachments": []
},
{
"id": "1556352159124029598",
"channel_id": "1556352159103058073",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556352159090475158",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "",
"timestamp": "2026-10-04T17:07:50.360Z",
"attachments": [],
"author_id": "1556352159077892243",
"author_username": "target_a91da2c28aae",
"author_global_name": null,
"author_discriminator": "2089",
"author_avatar": "f829b914",
"webhook_id": null,
"author_bot": false,
"missing_attachments": []
},
{
"id": "1556352159128223903",
"channel_id": "1556352159103058073",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556352159090475158",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "Anyone up for a trade this weekend?",
"timestamp": "2026-10-04T17:07:50.361Z",
"attachments": [],
"author_id": "1556352159077892243",
"author_username": "target_a91da2c28aae",
"author_global_name": null,
"author_discriminator": "2089",
"author_avatar": "f829b914",
"webhook_id": null,
"author_bot": false,
"missing_attachments": []
},
{
"id": "1556352159140806818",
"channel_id": "1556352159103058073",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556352159090475158",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "Weekly bulletin: the garden swap starts Saturday.",
"timestamp": "2026-10-04T17:07:50.364Z",
"attachments": [],
"author_id": "1556352159132418208",
"author_username": "Harbor Bulletin",
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": null,
"webhook_id": "1556352159132418208",
"author_bot": null,
"missing_attachments": []
},
{
"id": "1556352159153389733",
"channel_id": "1556352159103058073",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556352159090475158",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "",
"timestamp": "2026-10-04T17:07:50.367Z",
"attachments": [],
"author_id": "1556352159149195428",
"author_username": "Harbor_Helper",
"author_global_name": null,
"author_discriminator": "3966",
"author_avatar": null,
"webhook_id": null,
"author_bot": true,
"missing_attachments": []
},
{
"id": "1556352159161778343",
"channel_id": "1556352159103058073",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556352159090475158",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "Reminder: the garden swap moved to 10 AM.",
"timestamp": "2026-10-04T17:07:50.369Z",
"attachments": [],
"author_id": "1556352159149195428",
"author_username": "Harbor_Helper",
"author_global_name": null,
"author_discriminator": "3966",
"author_avatar": null,
"webhook_id": null,
"author_bot": true,
"missing_attachments": []
},
{
"id": "1556352159212110003",
"channel_id": "1556352159103058073",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556352159090475158",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "Night relay: the swap tables are packed away.",
"timestamp": "2026-10-04T17:07:50.381Z",
"attachments": [],
"author_id": "1556352159203721393",
"author_username": "Night Relay",
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": null,
"webhook_id": "1556352159203721393",
"author_bot": null,
"missing_attachments": []
},
{
"id": "1556352159186944173",
"channel_id": "1556352159103058073",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556352159090475158",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "",
"timestamp": "2026-10-04T17:07:50.375Z",
"attachments": [],
"author_id": "1556352159216304308",
"author_username": "DeletedUser",
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": null,
"webhook_id": null,
"author_bot": null,
"missing_attachments": []
}
],
"message_responses": [
{
"id": "1556352159115640988",
"channel_id": "1556352159103058073",
"author": {
"id": "1556352159069503633",
"username": "reporter_c03a2ee3db86",
"discriminator": "2642",
"global_name": "Avery Reporter",
"avatar": null,
"avatar_color": null,
"flags": 0
},
"webhook_id": null,
"type": 7,
"flags": 0,
"content": "",
"timestamp": "2026-10-04T17:07:50.358Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556352159119835293",
"channel_id": "1556352159103058073",
"author": {
"id": "1556352159073697938",
"username": "unverified_7d9456caf467",
"discriminator": "4822",
"global_name": "Unverified Reporter",
"avatar": null,
"avatar_color": null,
"flags": 0
},
"webhook_id": null,
"type": 7,
"flags": 0,
"content": "",
"timestamp": "2026-10-04T17:07:50.359Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556352159124029598",
"channel_id": "1556352159103058073",
"author": {
"id": "1556352159077892243",
"username": "target_a91da2c28aae",
"discriminator": "2089",
"global_name": "Jordan Target",
"avatar": "f829b914",
"avatar_color": 4604377,
"flags": 0
},
"webhook_id": null,
"type": 7,
"flags": 0,
"content": "",
"timestamp": "2026-10-04T17:07:50.360Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556352159128223903",
"channel_id": "1556352159103058073",
"author": {
"id": "1556352159077892243",
"username": "target_a91da2c28aae",
"discriminator": "2089",
"global_name": "Jordan Target",
"avatar": "f829b914",
"avatar_color": 4604377,
"flags": 0
},
"webhook_id": null,
"type": 0,
"flags": 0,
"content": "Anyone up for a trade this weekend?",
"timestamp": "2026-10-04T17:07:50.361Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556352159140806818",
"channel_id": "1556352159103058073",
"author": {
"id": "1556352159132418208",
"username": "Harbor Bulletin",
"discriminator": "0000",
"global_name": "Harbor Bulletin",
"avatar": null,
"avatar_color": null,
"bot": true,
"flags": 0
},
"webhook_id": "1556352159132418208",
"type": 0,
"flags": 0,
"content": "Weekly bulletin: the garden swap starts Saturday.",
"timestamp": "2026-10-04T17:07:50.364Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556352159153389733",
"channel_id": "1556352159103058073",
"author": {
"id": "1556352159149195428",
"username": "Harbor_Helper",
"discriminator": "3966",
"global_name": null,
"avatar": null,
"avatar_color": null,
"bot": true,
"flags": 0
},
"webhook_id": null,
"type": 7,
"flags": 0,
"content": "",
"timestamp": "2026-10-04T17:07:50.367Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556352159161778343",
"channel_id": "1556352159103058073",
"author": {
"id": "1556352159149195428",
"username": "Harbor_Helper",
"discriminator": "3966",
"global_name": null,
"avatar": null,
"avatar_color": null,
"bot": true,
"flags": 0
},
"webhook_id": null,
"type": 0,
"flags": 0,
"content": "Reminder: the garden swap moved to 10 AM.",
"timestamp": "2026-10-04T17:07:50.369Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556352159212110003",
"channel_id": "1556352159103058073",
"author": {
"id": "1556352159203721393",
"username": "Night Relay",
"discriminator": "0000",
"global_name": "Night Relay",
"avatar": null,
"avatar_color": null,
"bot": true,
"flags": 0
},
"webhook_id": "1556352159203721393",
"type": 0,
"flags": 0,
"content": "Night relay: the swap tables are packed away.",
"timestamp": "2026-10-04T17:07:50.381Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556352159186944173",
"channel_id": "1556352159103058073",
"author": {
"id": "1556352159216304308",
"username": "DeletedUser",
"discriminator": "0000",
"global_name": "Deleted User",
"avatar": null,
"avatar_color": null,
"flags": 0
},
"webhook_id": null,
"type": 7,
"flags": 0,
"content": "",
"timestamp": "2026-10-04T17:07:50.375Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
}
],
"reported_profile_snapshot": {
"captured_at": "2026-10-04T17:12:47.653Z",
"user": {
"id": "1556352159149195428",
"username": "Harbor_Helper",
"discriminator": "3966",
"global_name": null,
"bio": null,
"pronouns": null,
"avatar": null,
"banner": null
},
"member": {
"guild_id": "1556352159090475158",
"nick": null,
"bio": null,
"pronouns": null,
"joined_at": "2026-10-04T17:12:47.614Z",
"avatar": null,
"banner": null
},
"guild": null
},
"reason": "spam",
"reason_label": "Spam",
"reason_highest_priority": false,
"flow": {
"revision_hash": "16781a19a797f23f",
"surface": "in_app",
"locale": "en-US",
"steps": [
{
"screen_id": "root_message",
"screen_title": "Report message",
"option_id": "spam",
"option_label": "Spam or unwanted ads",
"items": []
}
]
},
"reporter_good_faith_confirmed": null
}
@@ -1,572 +0,0 @@
{
"report_id": "1556114449264280806",
"reporter_id": "1556114449113285826",
"reporter_tag": "reporter_010908032cb3#7915",
"reporter_username": "reporter_010908032cb3",
"reporter_global_name": "Avery Reporter",
"reporter_discriminator": "7915",
"reporter_email": "[email protected]",
"reporter_full_legal_name": null,
"reporter_country_of_residence": null,
"reported_at": "2026-10-04T01:30:41.372Z",
"status": 0,
"report_type": 0,
"category": "spam",
"additional_info": null,
"reported_user_id": null,
"reported_user_tag": null,
"reported_user_username": null,
"reported_user_global_name": null,
"reported_user_discriminator": null,
"reported_user_avatar_hash": null,
"reported_webhook_id": "1556114449176200401",
"reported_webhook_name": "Harbor Bulletin",
"reported_webhook_avatar_hash": null,
"reported_webhook_default_name": "Harbor Bulletin",
"reported_webhook_default_avatar_hash": null,
"reported_webhook_type": 1,
"reported_webhook_application_id": null,
"reported_webhook_channel_id": "1556114449146840266",
"reported_webhook_guild_id": "1556114449134257351",
"reported_webhook_created_at": "2026-10-04T01:23:15.892Z",
"reported_webhook_creator_id": "1556114449125868741",
"reported_webhook_creator_tag": "owner_aa696aafce86#7021",
"reported_webhook_creator_username": "owner_aa696aafce86",
"reported_webhook_creator_global_name": "Morgan Owner",
"reported_webhook_creator_discriminator": "7021",
"reported_webhook_creator_avatar_hash": null,
"reported_guild_id": "1556114449134257351",
"reported_guild_name": "Harbor Commons",
"reported_guild_icon_hash": null,
"reported_message_id": "1556114449184589011",
"reported_channel_id": "1556114449146840266",
"reported_channel_name": "general",
"reported_channel_nsfw": false,
"reported_guild_invite_code": null,
"reported_guild_nsfw_level": 0,
"reported_guild_nsfw": false,
"reported_guild_content_warning_level": 0,
"reported_guild_content_warning_text": null,
"reported_channel_nsfw_override": null,
"reported_channel_content_warning_level": 0,
"reported_channel_content_warning_text": null,
"reported_channel_effective_nsfw": false,
"reported_channel_effective_content_warning_level": 0,
"reported_channel_effective_content_warning_text": null,
"resolved_at": null,
"resolved_by_admin_id": null,
"public_comment": null,
"mutual_dm_channel_id": null,
"message_context": [
{
"id": "1556114449159423181",
"channel_id": "1556114449146840266",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556114449134257351",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "",
"timestamp": "2026-10-04T01:23:15.888Z",
"attachments": [],
"author_id": "1556114449113285826",
"author_username": "reporter_010908032cb3",
"author_global_name": null,
"author_discriminator": "7915",
"author_avatar": null,
"webhook_id": null
},
{
"id": "1556114449163617486",
"channel_id": "1556114449146840266",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556114449134257351",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "",
"timestamp": "2026-10-04T01:23:15.889Z",
"attachments": [],
"author_id": "1556114449117480131",
"author_username": "unverified_b8bab8c7a8c5",
"author_global_name": null,
"author_discriminator": "0943",
"author_avatar": null,
"webhook_id": null
},
{
"id": "1556114449167811791",
"channel_id": "1556114449146840266",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556114449134257351",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "",
"timestamp": "2026-10-04T01:23:15.890Z",
"attachments": [],
"author_id": "1556114449121674436",
"author_username": "target_8b1ee6a349df",
"author_global_name": null,
"author_discriminator": "9014",
"author_avatar": "f829b914",
"webhook_id": null
},
{
"id": "1556114449172006096",
"channel_id": "1556114449146840266",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556114449134257351",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "Anyone up for a trade this weekend?",
"timestamp": "2026-10-04T01:23:15.891Z",
"attachments": [],
"author_id": "1556114449121674436",
"author_username": "target_8b1ee6a349df",
"author_global_name": null,
"author_discriminator": "9014",
"author_avatar": "f829b914",
"webhook_id": null
},
{
"id": "1556114449184589011",
"channel_id": "1556114449146840266",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556114449134257351",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "Weekly bulletin: the garden swap starts Saturday.",
"timestamp": "2026-10-04T01:23:15.894Z",
"attachments": [],
"author_id": "1556114449176200401",
"author_username": "Harbor Bulletin",
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": null,
"webhook_id": "1556114449176200401"
},
{
"id": "1556114449255892196",
"channel_id": "1556114449146840266",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556114449134257351",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "Night relay: the swap tables are packed away.",
"timestamp": "2026-10-04T01:23:15.911Z",
"attachments": [],
"author_id": "1556114449247503586",
"author_username": "Night Relay",
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": null,
"webhook_id": "1556114449247503586"
},
{
"id": "1556114449230726366",
"channel_id": "1556114449146840266",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556114449134257351",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "",
"timestamp": "2026-10-04T01:23:15.905Z",
"attachments": [],
"author_id": "1556114449260086501",
"author_username": "DeletedUser",
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": null,
"webhook_id": null
},
{
"id": "1556114449205560536",
"channel_id": "1556114449146840266",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556114449134257351",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "Reminder: the garden swap moved to 10 AM.",
"timestamp": "2026-10-04T01:23:15.899Z",
"attachments": [],
"author_id": "1556114449192977621",
"author_username": "Harbor_Helper",
"author_global_name": null,
"author_discriminator": "1238",
"author_avatar": null,
"webhook_id": null
},
{
"id": "1556114449197171926",
"channel_id": "1556114449146840266",
"channel_nsfw": false,
"channel_content_warning_level": null,
"channel_content_warning_text": null,
"guild_id": "1556114449134257351",
"guild_nsfw_level": 0,
"guild_nsfw": null,
"guild_content_warning_level": null,
"guild_content_warning_text": null,
"content": "",
"timestamp": "2026-10-04T01:23:15.897Z",
"attachments": [],
"author_id": "1556114449192977621",
"author_username": "Harbor_Helper",
"author_global_name": null,
"author_discriminator": "1238",
"author_avatar": null,
"webhook_id": null
}
],
"message_responses": [
{
"id": "1556114449159423181",
"channel_id": "1556114449146840266",
"author": {
"id": "1556114449113285826",
"username": "reporter_010908032cb3",
"discriminator": "7915",
"global_name": "Avery Reporter",
"avatar": null,
"avatar_color": null,
"flags": 0
},
"webhook_id": null,
"type": 7,
"flags": 0,
"content": "",
"timestamp": "2026-10-04T01:23:15.888Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556114449163617486",
"channel_id": "1556114449146840266",
"author": {
"id": "1556114449117480131",
"username": "unverified_b8bab8c7a8c5",
"discriminator": "0943",
"global_name": "Unverified Reporter",
"avatar": null,
"avatar_color": null,
"flags": 0
},
"webhook_id": null,
"type": 7,
"flags": 0,
"content": "",
"timestamp": "2026-10-04T01:23:15.889Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556114449167811791",
"channel_id": "1556114449146840266",
"author": {
"id": "1556114449121674436",
"username": "target_8b1ee6a349df",
"discriminator": "9014",
"global_name": "Jordan Target",
"avatar": "f829b914",
"avatar_color": 4604377,
"flags": 0
},
"webhook_id": null,
"type": 7,
"flags": 0,
"content": "",
"timestamp": "2026-10-04T01:23:15.890Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556114449172006096",
"channel_id": "1556114449146840266",
"author": {
"id": "1556114449121674436",
"username": "target_8b1ee6a349df",
"discriminator": "9014",
"global_name": "Jordan Target",
"avatar": "f829b914",
"avatar_color": 4604377,
"flags": 0
},
"webhook_id": null,
"type": 0,
"flags": 0,
"content": "Anyone up for a trade this weekend?",
"timestamp": "2026-10-04T01:23:15.891Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556114449184589011",
"channel_id": "1556114449146840266",
"author": {
"id": "1556114449176200401",
"username": "Harbor Bulletin",
"discriminator": "0000",
"global_name": "Harbor Bulletin",
"avatar": null,
"avatar_color": null,
"bot": true,
"flags": 0
},
"webhook_id": "1556114449176200401",
"type": 0,
"flags": 0,
"content": "Weekly bulletin: the garden swap starts Saturday.",
"timestamp": "2026-10-04T01:23:15.894Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556114449255892196",
"channel_id": "1556114449146840266",
"author": {
"id": "1556114449247503586",
"username": "Night Relay",
"discriminator": "0000",
"global_name": "Night Relay",
"avatar": null,
"avatar_color": null,
"bot": true,
"flags": 0
},
"webhook_id": "1556114449247503586",
"type": 0,
"flags": 0,
"content": "Night relay: the swap tables are packed away.",
"timestamp": "2026-10-04T01:23:15.911Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556114449230726366",
"channel_id": "1556114449146840266",
"author": {
"id": "1556114449260086501",
"username": "DeletedUser",
"discriminator": "0000",
"global_name": "Deleted User",
"avatar": null,
"avatar_color": null,
"flags": 0
},
"webhook_id": null,
"type": 7,
"flags": 0,
"content": "",
"timestamp": "2026-10-04T01:23:15.905Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556114449205560536",
"channel_id": "1556114449146840266",
"author": {
"id": "1556114449192977621",
"username": "Harbor_Helper",
"discriminator": "1238",
"global_name": null,
"avatar": null,
"avatar_color": null,
"bot": true,
"flags": 0
},
"webhook_id": null,
"type": 0,
"flags": 0,
"content": "Reminder: the garden swap moved to 10 AM.",
"timestamp": "2026-10-04T01:23:15.899Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
},
{
"id": "1556114449197171926",
"channel_id": "1556114449146840266",
"author": {
"id": "1556114449192977621",
"username": "Harbor_Helper",
"discriminator": "1238",
"global_name": null,
"avatar": null,
"avatar_color": null,
"bot": true,
"flags": 0
},
"webhook_id": null,
"type": 7,
"flags": 0,
"content": "",
"timestamp": "2026-10-04T01:23:15.897Z",
"edited_timestamp": null,
"pinned": false,
"mention_everyone": false,
"tts": false,
"mentions": [],
"mention_roles": [],
"mention_channels": null,
"embeds": null,
"attachments": null,
"stickers": null,
"reactions": null,
"message_reference": null,
"message_snapshots": null,
"nonce": null,
"call": null
}
],
"reason": "spam",
"reason_label": "Spam",
"reason_highest_priority": false,
"flow": {
"revision_hash": "16781a19a797f23f",
"surface": "in_app",
"locale": "en-US",
"steps": [
{
"screen_id": "root_message",
"screen_title": "Report message",
"option_id": "spam",
"option_label": "Spam or unwanted ads",
"items": []
}
]
},
"reporter_good_faith_confirmed": null
}
@@ -1,21 +0,0 @@
{
"guilds": [
{
"id": "1600000000000000001",
"name": "Parity Guild",
"icon": null,
"banner": null,
"owner_id": "1508576042312688531",
"owner_username": "parity_user",
"owner_global_name": "Parity User",
"owner_discriminator": "4363",
"member_count": 128,
"features": ["COMMUNITY", "DISCOVERABLE"],
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": 0,
"content_warning_text": null
}
],
"total": 1
}
@@ -1,51 +0,0 @@
{
"reports": [
{
"report_id": "1700000000000000001",
"reporter_id": "1508576042312688531",
"reporter_tag": "parity_user#4363",
"reporter_username": "parity_user",
"reporter_global_name": "Parity User",
"reporter_discriminator": "4363",
"reporter_email": "[email protected]",
"reporter_full_legal_name": null,
"reporter_country_of_residence": null,
"reported_at": "2026-05-26T15:10:45.134Z",
"status": 0,
"report_type": 0,
"category": "nsfw_violation",
"additional_info": "Parity fixture report.",
"reported_user_id": "1508576042312688531",
"reported_user_tag": "parity_user#4363",
"reported_user_username": "parity_user",
"reported_user_global_name": "Parity User",
"reported_user_discriminator": "4363",
"reported_user_avatar_hash": null,
"reported_guild_id": "1600000000000000001",
"reported_guild_name": "Parity Guild",
"reported_message_id": "1800000000000000001",
"reported_channel_id": "1600000000000000101",
"reported_channel_name": "general",
"reported_channel_nsfw": false,
"reported_guild_invite_code": null,
"reported_guild_nsfw_level": 0,
"reported_guild_nsfw": false,
"reported_guild_content_warning_level": null,
"reported_guild_content_warning_text": null,
"reported_channel_nsfw_override": null,
"reported_channel_content_warning_level": null,
"reported_channel_content_warning_text": null,
"reported_channel_effective_nsfw": false,
"reported_channel_effective_content_warning_level": null,
"reported_channel_effective_content_warning_text": null,
"resolved_at": null,
"resolved_by_admin_id": null,
"public_comment": null,
"mutual_dm_channel_id": null,
"message_context": []
}
],
"total": 1,
"offset": 0,
"limit": 25
}
@@ -1,228 +0,0 @@
{
"reports": [
{
"report_id": "1556008115701286089",
"reporter_id": "1556008115617399989",
"reporter_tag": "reporter_f189c9468c17#0896",
"reporter_username": "reporter_f189c9468c17",
"reporter_global_name": "Avery Reporter",
"reporter_discriminator": "0896",
"reporter_email": "[email protected]",
"reporter_full_legal_name": null,
"reporter_country_of_residence": null,
"reported_at": "2026-10-03T18:30:33.848Z",
"status": 0,
"report_type": 0,
"category": "child_safety",
"additional_info": null,
"reported_user_id": "1556008115625788599",
"reported_user_tag": "target_4a5a5ce687ff#6741",
"reported_user_username": "target_4a5a5ce687ff",
"reported_user_global_name": "Jordan Target",
"reported_user_discriminator": "6741",
"reported_user_avatar_hash": "f829b914",
"reported_guild_id": null,
"reported_guild_name": null,
"reported_guild_icon_hash": null,
"reported_message_id": "1556008115697091784",
"reported_channel_id": "1556008115692897479",
"reported_channel_name": null,
"reported_channel_nsfw": false,
"reported_guild_invite_code": null,
"reported_guild_nsfw_level": null,
"reported_guild_nsfw": null,
"reported_guild_content_warning_level": null,
"reported_guild_content_warning_text": null,
"reported_channel_nsfw_override": null,
"reported_channel_content_warning_level": 0,
"reported_channel_content_warning_text": null,
"reported_channel_effective_nsfw": null,
"reported_channel_effective_content_warning_level": null,
"reported_channel_effective_content_warning_text": null,
"resolved_at": null,
"resolved_by_admin_id": null,
"public_comment": null,
"reason": "csam",
"reason_label": "Child sexual abuse material",
"reason_highest_priority": true,
"flow": {
"revision_hash": "16781a19a797f23f",
"surface": "in_app",
"locale": "en-US",
"steps": [
{
"screen_id": "root_message",
"screen_title": "Report message",
"option_id": "abuse",
"option_label": "Abusive or harmful content",
"items": []
},
{
"screen_id": "abuse",
"screen_title": "What does it involve?",
"option_id": "sexual",
"option_label": "Sexual content",
"items": []
},
{
"screen_id": "sexual",
"screen_title": "Tell us about the sexual content",
"option_id": "minor_sexual",
"option_label": "Sexualizing a minor",
"items": []
},
{
"screen_id": "minor_sexual",
"screen_title": "What did you see involving a minor?",
"option_id": "csam",
"option_label": "Real or AI-generated images or videos of child sexual abuse",
"items": []
}
]
},
"reporter_good_faith_confirmed": null
},
{
"report_id": "1556008115705480394",
"reporter_id": "1556008115617399989",
"reporter_tag": "reporter_f189c9468c17#0896",
"reporter_username": "reporter_f189c9468c17",
"reporter_global_name": "Avery Reporter",
"reporter_discriminator": "0896",
"reporter_email": "[email protected]",
"reporter_full_legal_name": null,
"reporter_country_of_residence": null,
"reported_at": "2026-10-03T18:30:33.852Z",
"status": 0,
"report_type": 1,
"category": "harassment",
"additional_info": null,
"reported_user_id": "1556008115625788599",
"reported_user_tag": "target_4a5a5ce687ff#6741",
"reported_user_username": "target_4a5a5ce687ff",
"reported_user_global_name": "Jordan Target",
"reported_user_discriminator": "6741",
"reported_user_avatar_hash": "f829b914",
"reported_guild_id": null,
"reported_guild_name": null,
"reported_guild_icon_hash": null,
"reported_message_id": null,
"reported_channel_id": null,
"reported_channel_name": null,
"reported_channel_nsfw": null,
"reported_guild_invite_code": null,
"reported_guild_nsfw_level": null,
"reported_guild_nsfw": null,
"reported_guild_content_warning_level": null,
"reported_guild_content_warning_text": null,
"reported_channel_nsfw_override": null,
"reported_channel_content_warning_level": null,
"reported_channel_content_warning_text": null,
"reported_channel_effective_nsfw": null,
"reported_channel_effective_content_warning_level": null,
"reported_channel_effective_content_warning_text": null,
"resolved_at": null,
"resolved_by_admin_id": null,
"public_comment": null,
"reason": "harassment",
"reason_label": "Harassment or bullying",
"reason_highest_priority": false,
"flow": {
"revision_hash": "22a1e877519c31ca",
"surface": "in_app",
"locale": "de",
"steps": [
{
"screen_id": "profile_intro",
"screen_title": "Report profile",
"option_id": null,
"option_label": null,
"items": []
},
{
"screen_id": "profile_parts",
"screen_title": "Which parts of their profile are a problem?",
"option_id": null,
"option_label": null,
"items": [
{
"id": "photo",
"label": "Pictures"
},
{
"id": "profile_text",
"label": "Profile text"
}
]
},
{
"screen_id": "root_user",
"screen_title": "What's wrong with their profile?",
"option_id": "abuse",
"option_label": "Abusive or harmful content",
"items": []
},
{
"screen_id": "profile_abuse",
"screen_title": "What's harmful about their profile?",
"option_id": "harassment",
"option_label": "Their profile harasses or targets me or someone else",
"items": []
}
]
},
"reporter_good_faith_confirmed": null
},
{
"report_id": "1556008115709674699",
"reporter_id": "1556008115617399989",
"reporter_tag": "reporter_f189c9468c17#0896",
"reporter_username": "reporter_f189c9468c17",
"reporter_global_name": "Avery Reporter",
"reporter_discriminator": "0896",
"reporter_email": "[email protected]",
"reporter_full_legal_name": null,
"reporter_country_of_residence": null,
"reported_at": "2026-10-03T18:30:33.856Z",
"status": 0,
"report_type": 0,
"category": "spam",
"additional_info": null,
"reported_user_id": "1556008115625788599",
"reported_user_tag": "target_4a5a5ce687ff#6741",
"reported_user_username": "target_4a5a5ce687ff",
"reported_user_global_name": "Jordan Target",
"reported_user_discriminator": "6741",
"reported_user_avatar_hash": "f829b914",
"reported_guild_id": "1556008115638371514",
"reported_guild_name": "Harbor Commons",
"reported_guild_icon_hash": null,
"reported_message_id": "1556008115676120259",
"reported_channel_id": "1556008115650954429",
"reported_channel_name": "general",
"reported_channel_nsfw": false,
"reported_guild_invite_code": null,
"reported_guild_nsfw_level": 0,
"reported_guild_nsfw": false,
"reported_guild_content_warning_level": 0,
"reported_guild_content_warning_text": null,
"reported_channel_nsfw_override": null,
"reported_channel_content_warning_level": 0,
"reported_channel_content_warning_text": null,
"reported_channel_effective_nsfw": false,
"reported_channel_effective_content_warning_level": 0,
"reported_channel_effective_content_warning_text": null,
"resolved_at": null,
"resolved_by_admin_id": null,
"public_comment": null,
"reason": null,
"reason_label": null,
"reason_highest_priority": null,
"flow": null,
"reporter_good_faith_confirmed": null
}
],
"total": 3,
"offset": 0,
"limit": 25
}
@@ -1,161 +0,0 @@
{
"reports": [
{
"report_id": "1556114449264280806",
"reporter_id": "1556114449113285826",
"reporter_tag": "reporter_010908032cb3#7915",
"reporter_username": "reporter_010908032cb3",
"reporter_global_name": "Avery Reporter",
"reporter_discriminator": "7915",
"reporter_email": "[email protected]",
"reporter_full_legal_name": null,
"reporter_country_of_residence": null,
"reported_at": "2026-10-04T01:30:41.372Z",
"status": 0,
"report_type": 0,
"category": "spam",
"additional_info": null,
"reported_user_id": null,
"reported_user_tag": null,
"reported_user_username": null,
"reported_user_global_name": null,
"reported_user_discriminator": null,
"reported_user_avatar_hash": null,
"reported_webhook_id": "1556114449176200401",
"reported_webhook_name": "Harbor Bulletin",
"reported_webhook_avatar_hash": null,
"reported_webhook_default_name": "Harbor Bulletin",
"reported_webhook_default_avatar_hash": null,
"reported_webhook_type": 1,
"reported_webhook_application_id": null,
"reported_webhook_channel_id": "1556114449146840266",
"reported_webhook_guild_id": "1556114449134257351",
"reported_webhook_created_at": "2026-10-04T01:23:15.892Z",
"reported_webhook_creator_id": "1556114449125868741",
"reported_webhook_creator_tag": "owner_aa696aafce86#7021",
"reported_webhook_creator_username": "owner_aa696aafce86",
"reported_webhook_creator_global_name": "Morgan Owner",
"reported_webhook_creator_discriminator": "7021",
"reported_webhook_creator_avatar_hash": null,
"reported_guild_id": "1556114449134257351",
"reported_guild_name": "Harbor Commons",
"reported_guild_icon_hash": null,
"reported_message_id": "1556114449184589011",
"reported_channel_id": "1556114449146840266",
"reported_channel_name": "general",
"reported_channel_nsfw": false,
"reported_guild_invite_code": null,
"reported_guild_nsfw_level": 0,
"reported_guild_nsfw": false,
"reported_guild_content_warning_level": 0,
"reported_guild_content_warning_text": null,
"reported_channel_nsfw_override": null,
"reported_channel_content_warning_level": 0,
"reported_channel_content_warning_text": null,
"reported_channel_effective_nsfw": false,
"reported_channel_effective_content_warning_level": 0,
"reported_channel_effective_content_warning_text": null,
"resolved_at": null,
"resolved_by_admin_id": null,
"public_comment": null,
"reason": "spam",
"reason_label": "Spam",
"reason_highest_priority": false,
"flow": {
"revision_hash": "16781a19a797f23f",
"surface": "in_app",
"locale": "en-US",
"steps": [
{
"screen_id": "root_message",
"screen_title": "Report message",
"option_id": "spam",
"option_label": "Spam or unwanted ads",
"items": []
}
]
},
"reporter_good_faith_confirmed": null
},
{
"report_id": "1556114449268475111",
"reporter_id": "1556114449113285826",
"reporter_tag": "reporter_010908032cb3#7915",
"reporter_username": "reporter_010908032cb3",
"reporter_global_name": "Avery Reporter",
"reporter_discriminator": "7915",
"reporter_email": "[email protected]",
"reporter_full_legal_name": null,
"reporter_country_of_residence": null,
"reported_at": "2026-10-04T01:30:41.375Z",
"status": 0,
"report_type": 0,
"category": "spam",
"additional_info": null,
"reported_user_id": "1556114449192977621",
"reported_user_tag": "Harbor_Helper#1238",
"reported_user_username": "Harbor_Helper",
"reported_user_global_name": null,
"reported_user_discriminator": "1238",
"reported_user_avatar_hash": null,
"reported_webhook_id": null,
"reported_webhook_name": null,
"reported_webhook_avatar_hash": null,
"reported_webhook_default_name": null,
"reported_webhook_default_avatar_hash": null,
"reported_webhook_type": null,
"reported_webhook_application_id": null,
"reported_webhook_channel_id": null,
"reported_webhook_guild_id": null,
"reported_webhook_created_at": null,
"reported_webhook_creator_id": null,
"reported_webhook_creator_tag": null,
"reported_webhook_creator_username": null,
"reported_webhook_creator_global_name": null,
"reported_webhook_creator_discriminator": null,
"reported_webhook_creator_avatar_hash": null,
"reported_guild_id": "1556114449134257351",
"reported_guild_name": "Harbor Commons",
"reported_guild_icon_hash": null,
"reported_message_id": "1556114449205560536",
"reported_channel_id": "1556114449146840266",
"reported_channel_name": "general",
"reported_channel_nsfw": false,
"reported_guild_invite_code": null,
"reported_guild_nsfw_level": 0,
"reported_guild_nsfw": false,
"reported_guild_content_warning_level": 0,
"reported_guild_content_warning_text": null,
"reported_channel_nsfw_override": null,
"reported_channel_content_warning_level": 0,
"reported_channel_content_warning_text": null,
"reported_channel_effective_nsfw": false,
"reported_channel_effective_content_warning_level": 0,
"reported_channel_effective_content_warning_text": null,
"resolved_at": null,
"resolved_by_admin_id": null,
"public_comment": null,
"reason": "spam",
"reason_label": "Spam",
"reason_highest_priority": false,
"flow": {
"revision_hash": "16781a19a797f23f",
"surface": "in_app",
"locale": "en-US",
"steps": [
{
"screen_id": "root_message",
"screen_title": "Report message",
"option_id": "spam",
"option_label": "Spam or unwanted ads",
"items": []
}
]
},
"reporter_good_faith_confirmed": null
}
],
"total": 2,
"offset": 0,
"limit": 50
}
@@ -1,46 +0,0 @@
{
"users": [
{
"id": "1508576042312688531",
"username": "parity_user",
"discriminator": 4363,
"global_name": "Parity User",
"bot": false,
"system": false,
"flags": "0",
"premium_flags": 0,
"avatar": null,
"banner": null,
"bio": null,
"pronouns": null,
"accent_color": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"date_of_birth": null,
"locale": "en-US",
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"acls": [],
"traits": [],
"has_totp": false,
"authenticator_types": [],
"last_active_at": "2026-05-26T15:00:00.000Z",
"last_active_ip": "127.0.0.1",
"last_active_ip_reverse": "localhost",
"last_active_location": "Local"
}
],
"total": 1
}
@@ -1,59 +0,0 @@
{
"routes": [
{
"method": "GET",
"path": "/admin/users/@me",
"body_file": "admin_user_me.json"
},
{
"method": "GET",
"path": "/users/@me",
"body_file": "oauth_user_me.json"
},
{
"method": "POST",
"path": "/oauth2/token",
"body_file": "oauth_token.json"
},
{
"method": "POST",
"path": "/oauth2/token/revoke",
"body": "{}"
},
{
"method": "GET",
"path": "/admin/users",
"body_file": "search_users.json"
},
{
"method": "GET",
"path": "/admin/users/1508576042312688531",
"body_file": "lookup_user.json"
},
{
"method": "GET",
"path": "/admin/guilds",
"body_file": "search_guilds.json"
},
{
"method": "GET",
"path": "/admin/guilds/1600000000000000001",
"body_file": "lookup_guild.json"
},
{
"method": "GET",
"path": "/admin/reports",
"body_file": "search_reports.json"
},
{
"method": "GET",
"path": "/admin/reports/1700000000000000001",
"body_file": "report_detail.json"
},
{
"method": "GET",
"path": "/admin/api-keys",
"body": "[]"
}
]
}
@@ -1,648 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde_json::Value;
#[derive(Clone, Debug, Eq, PartialEq)]
struct Attribute {
name: String,
value: Option<String>,
}
pub fn normalize_body(content_type: Option<&str>, body: &str) -> String {
let content_type = content_type.unwrap_or("").to_ascii_lowercase();
if content_type.contains("css") {
return normalize_css_body(body);
}
if content_type.contains("html") {
return normalize_html(body);
}
if content_type.contains("json")
&& let Ok(value) = serde_json::from_str::<Value>(body)
{
return serde_json::to_string(&value).unwrap_or_else(|_| normalize_text(body));
}
normalize_text(body)
}
fn normalize_css_body(body: &str) -> String {
if body.trim().is_empty() || body.contains("not available") {
"__CSS_EMPTY__".to_owned()
} else {
"__CSS_OK__".to_owned()
}
}
pub fn normalize_html(input: &str) -> String {
let input = normalize_text(input);
let mut output = String::with_capacity(input.len());
let mut cursor = 0;
while let Some(relative_start) = input[cursor..].find('<') {
let start = cursor + relative_start;
output.push_str(&input[cursor..start]);
let Some(end) = find_tag_end(&input, start) else {
output.push_str(&input[start..]);
return finish_html(output);
};
let raw_tag = &input[start..=end];
output.push_str(&normalize_tag(raw_tag));
cursor = end + 1;
}
output.push_str(&input[cursor..]);
finish_html(output)
}
pub fn normalize_text(input: &str) -> String {
let mut value = input.replace("\r\n", "\n").replace('\r', "\n");
value = normalize_host_ports(&value);
value = strip_static_asset_queries(&value);
value = strip_htmx_script_tags(&value);
value = strip_rust_runtime_script_blocks(&value);
value = normalize_intentional_rust_improvements(&value);
value = replace_query_parameter_values(&value, "state", "__OAUTH_STATE__");
value = replace_query_parameter_values(&value, "_csrf", "__CSRF_TOKEN__");
value = replace_script_csrf_values(&value);
value = normalize_cookie_tokens(&value);
value.trim().to_owned()
}
pub fn normalize_header_value(name: &str, value: &str) -> String {
match name.to_ascii_lowercase().as_str() {
"content-type" => normalize_content_type(value),
"location" => normalize_text(value),
"cookie" | "set-cookie" => normalize_cookie_header(value),
_ => normalize_text(value),
}
}
pub fn normalize_content_type(value: &str) -> String {
value
.split(';')
.map(|part| part.trim().to_ascii_lowercase())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("; ")
}
pub fn normalize_cookie_header(value: &str) -> String {
normalize_cookie_tokens(value)
.split(';')
.map(str::trim)
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("; ")
}
fn replace_script_csrf_values(input: &str) -> String {
let pattern = "var csrf=\"";
let mut output = String::with_capacity(input.len());
let mut cursor = 0;
while let Some(relative_start) = input[cursor..].find(pattern) {
let start = cursor + relative_start;
let token_start = start + pattern.len();
output.push_str(&input[cursor..token_start]);
let Some(relative_end) = input[token_start..].find('"') else {
cursor = token_start;
break;
};
let token_end = token_start + relative_end;
let token = &input[token_start..token_end];
if token.len() == 64 && token.chars().all(|ch| ch.is_ascii_hexdigit()) {
output.push_str("__CSRF_TOKEN__");
} else {
output.push_str(token);
}
cursor = token_end;
}
output.push_str(&input[cursor..]);
output
}
fn finish_html(output: String) -> String {
let output = remove_empty_flash_container(&output);
let output = decode_html_entities(&output);
remove_between_tag_whitespace(&output).trim().to_owned()
}
fn remove_empty_flash_container(input: &str) -> String {
input
.replace(
r#"<div class="empty:hidden" id="flash-container"></div>"#,
"",
)
.replace(
r#"<div id="flash-container" class="empty:hidden"></div>"#,
"",
)
}
fn find_tag_end(input: &str, start: usize) -> Option<usize> {
let mut quote: Option<char> = None;
for (offset, ch) in input[start + 1..].char_indices() {
match (quote, ch) {
(Some(active), current) if current == active => quote = None,
(None, '"' | '\'') => quote = Some(ch),
(None, '>') => return Some(start + 1 + offset),
_ => {}
}
}
None
}
fn normalize_tag(raw: &str) -> String {
if raw.starts_with("<!--") {
return String::new();
}
if raw.len() < 3 {
return raw.to_owned();
}
let mut inner = raw[1..raw.len() - 1].trim();
let self_closing = inner.ends_with('/');
if self_closing {
inner = inner[..inner.len() - 1].trim_end();
}
if inner.eq_ignore_ascii_case("!doctype html") {
return String::new();
}
if inner.starts_with('!') || inner.starts_with('?') {
return format!("<{}>", collapse_ascii_whitespace(inner));
}
if let Some(rest) = inner.strip_prefix('/') {
return format!("</{}>", rest.trim().to_ascii_lowercase());
}
let (tag_name, rest) = split_tag_name(inner);
let tag_name = tag_name.to_ascii_lowercase();
let mut attributes = parse_attributes(rest);
normalize_csrf_input_value(&tag_name, &mut attributes);
let tag_attributes = attributes.clone();
attributes.retain(|attribute| {
!is_ignored_rust_runtime_attribute(&tag_name, &tag_attributes, attribute)
});
for attribute in &mut attributes {
if let Some(value) = attribute.value.take() {
attribute.value = Some(normalize_text(&decode_html_entities(&value)));
}
attribute.name = attribute.name.to_ascii_lowercase();
if attribute.name == "class"
&& let Some(ref mut value) = attribute.value
{
let mut tokens: Vec<&str> = value.split_whitespace().collect();
tokens.retain(|token| *token != "font-normal");
tokens.sort();
*value = tokens.join(" ");
}
if attribute.value.as_deref() == Some("") && is_boolean_attribute(&attribute.name) {
attribute.value = None;
}
}
attributes.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.value.cmp(&right.value))
});
let mut normalized = format!("<{tag_name}");
for attribute in attributes {
normalized.push(' ');
normalized.push_str(&attribute.name);
if let Some(value) = attribute.value {
normalized.push_str("=\"");
normalized.push_str(&escape_attr(&value));
normalized.push('"');
}
}
normalized.push('>');
if self_closing && !is_void_element(&tag_name) {
normalized.push_str("</");
normalized.push_str(&tag_name);
normalized.push('>');
}
normalized
}
fn split_tag_name(inner: &str) -> (&str, &str) {
let split_at = inner
.find(|ch: char| ch.is_ascii_whitespace())
.unwrap_or(inner.len());
let name = &inner[..split_at];
let rest = inner[split_at..].trim_start();
(name, rest)
}
fn parse_attributes(input: &str) -> Vec<Attribute> {
let bytes = input.as_bytes();
let mut attributes = Vec::new();
let mut cursor = 0;
while cursor < bytes.len() {
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= bytes.len() {
break;
}
let name_start = cursor;
while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'='
{
cursor += 1;
}
if name_start == cursor {
cursor += 1;
continue;
}
let name = input[name_start..cursor].to_owned();
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= bytes.len() || bytes[cursor] != b'=' {
attributes.push(Attribute { name, value: None });
continue;
}
cursor += 1;
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= bytes.len() {
attributes.push(Attribute {
name,
value: Some(String::new()),
});
break;
}
let value = if bytes[cursor] == b'"' || bytes[cursor] == b'\'' {
let quote = bytes[cursor];
cursor += 1;
let value_start = cursor;
while cursor < bytes.len() && bytes[cursor] != quote {
cursor += 1;
}
let value = input[value_start..cursor].to_owned();
if cursor < bytes.len() {
cursor += 1;
}
value
} else {
let value_start = cursor;
while cursor < bytes.len() && !bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
input[value_start..cursor].to_owned()
};
attributes.push(Attribute {
name,
value: Some(value),
});
}
attributes
}
fn normalize_csrf_input_value(tag_name: &str, attributes: &mut [Attribute]) {
if tag_name != "input" {
return;
}
let is_csrf = attributes.iter().any(|attribute| {
attribute.name.eq_ignore_ascii_case("name") && attribute.value.as_deref() == Some("_csrf")
});
if !is_csrf {
return;
}
for attribute in attributes {
if attribute.name.eq_ignore_ascii_case("value") {
attribute.value = Some("__CSRF_TOKEN__".to_owned());
}
}
}
fn is_ignored_rust_runtime_attribute(
tag_name: &str,
tag_attributes: &[Attribute],
attribute: &Attribute,
) -> bool {
let name = attribute.name.to_ascii_lowercase();
if name.starts_with("hx-") {
return true;
}
if matches!(
name.as_str(),
"popovertarget" | "popovertargetaction" | "popover"
) {
return true;
}
let is_drawer_panel = tag_name == "aside"
&& tag_attributes
.iter()
.any(|attr| attr.name.eq_ignore_ascii_case("data-drawer-panel"));
if is_drawer_panel && matches!(name.as_str(), "id" | "aria-hidden") {
return true;
}
let is_drawer_body = tag_attributes
.iter()
.any(|attr| attr.name.eq_ignore_ascii_case("data-drawer-body"));
if is_drawer_body && name == "id" {
return true;
}
if name != "id" {
return false;
}
matches!(
attribute.value.as_deref(),
Some(
"users-results"
| "guilds-results"
| "applications-results"
| "reports-results"
| "user-peek"
| "guild-peek"
| "report-peek"
| "guild-tab-content"
| "user-tab-content"
| "jobs-results"
)
)
}
fn is_boolean_attribute(name: &str) -> bool {
if name.starts_with("data-") {
return true;
}
matches!(
name,
"allowfullscreen"
| "async"
| "autofocus"
| "autoplay"
| "checked"
| "controls"
| "defer"
| "disabled"
| "hidden"
| "loop"
| "multiple"
| "muted"
| "open"
| "readonly"
| "required"
| "selected"
)
}
fn is_void_element(name: &str) -> bool {
matches!(
name,
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr"
)
}
fn escape_attr(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn decode_html_entities(value: &str) -> String {
value
.replace("&amp;", "&")
.replace("&#39;", "'")
.replace("&#x27;", "'")
.replace("&apos;", "'")
.replace("&quot;", "\"")
.replace("&larr;", "\u{2190}")
.replace("&rarr;", "\u{2192}")
.replace("&mdash;", "\u{2014}")
.replace("&ndash;", "\u{2013}")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
fn collapse_ascii_whitespace(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn remove_between_tag_whitespace(input: &str) -> String {
let chars = input.chars().collect::<Vec<_>>();
let mut output = String::with_capacity(input.len());
let mut cursor = 0;
while cursor < chars.len() {
output.push(chars[cursor]);
if chars[cursor] == '>' {
cursor += 1;
let whitespace_start = cursor;
while cursor < chars.len() && chars[cursor].is_whitespace() {
cursor += 1;
}
if cursor < chars.len() && chars[cursor] == '<' {
continue;
}
if cursor > whitespace_start {
output.push(' ');
}
continue;
}
cursor += 1;
}
output
}
fn normalize_host_ports(input: &str) -> String {
let value = replace_host_port(input, "127.0.0.1");
replace_host_port(&value, "localhost")
}
fn replace_host_port(input: &str, host: &str) -> String {
let needle = format!("{host}:");
let mut output = String::with_capacity(input.len());
let mut cursor = 0;
while let Some(relative) = input[cursor..].find(&needle) {
let start = cursor + relative;
output.push_str(&input[cursor..start]);
output.push_str(host);
output.push_str(":__PORT__");
cursor = start + needle.len();
while cursor < input.len() && input.as_bytes()[cursor].is_ascii_digit() {
cursor += 1;
}
}
output.push_str(&input[cursor..]);
output
}
fn strip_static_asset_queries(input: &str) -> String {
let mut value = strip_query_after_path(input, "/static/app.css");
value = strip_query_after_path(&value, "/static/htmx.min.js");
value
}
fn strip_htmx_script_tags(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut cursor = 0;
while let Some(relative_start) = input[cursor..].find("<script") {
let start = cursor + relative_start;
let Some(tag_end) = find_tag_end(input, start) else {
break;
};
let tag = &input[start..=tag_end];
if tag.to_ascii_lowercase().contains("/static/htmx.min.js") {
output.push_str(&input[cursor..start]);
let after_tag = tag_end + 1;
if input[after_tag..].starts_with("</script>") {
cursor = after_tag + "</script>".len();
} else {
cursor = after_tag;
}
continue;
}
output.push_str(&input[cursor..=tag_end]);
cursor = tag_end + 1;
}
output.push_str(&input[cursor..]);
output
}
fn strip_rust_runtime_script_blocks(input: &str) -> String {
let mut value =
strip_script_blocks_containing(input, "document.body.addEventListener('showFlash'");
value = strip_script_blocks_containing(&value, "__fluxerAdminHtmxScrollPreserver");
value = strip_script_blocks_containing(&value, "window.__adminCopyToClipboard");
value = strip_script_blocks_containing(&value, "window.__fluxerDrawerInit");
value
}
fn strip_script_blocks_containing(input: &str, needle: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut cursor = 0;
while let Some(relative_start) = input[cursor..].find("<script") {
let start = cursor + relative_start;
let Some(tag_end) = find_tag_end(input, start) else {
break;
};
let content_start = tag_end + 1;
let Some(relative_end) = input[content_start..].find("</script>") else {
break;
};
let end = content_start + relative_end + "</script>".len();
let block = &input[start..end];
if block.contains(needle) {
output.push_str(&input[cursor..start]);
cursor = end;
continue;
}
output.push_str(&input[cursor..end]);
cursor = end;
}
output.push_str(&input[cursor..]);
output
}
fn normalize_intentional_rust_improvements(input: &str) -> String {
input.replace(
"flex flex-col gap-8 items-center",
"flex flex-col gap-8 items-stretch",
)
}
fn strip_query_after_path(input: &str, path: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut cursor = 0;
while let Some(relative) = input[cursor..].find(path) {
let start = cursor + relative;
output.push_str(&input[cursor..start]);
output.push_str(path);
cursor = start + path.len();
if input[cursor..].starts_with('?') {
cursor += 1;
while cursor < input.len() && !is_url_delimiter(input.as_bytes()[cursor]) {
cursor += 1;
}
}
}
output.push_str(&input[cursor..]);
output
}
fn replace_query_parameter_values(input: &str, name: &str, replacement: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut cursor = 0;
while cursor < input.len() {
let remaining = &input[cursor..];
let query_needle = format!("?{name}=");
let amp_needle = format!("&{name}=");
let query_pos = remaining.find(&query_needle);
let amp_pos = remaining.find(&amp_needle);
let Some((relative, needle_len)) =
closest_match(query_pos, query_needle.len(), amp_pos, amp_needle.len())
else {
output.push_str(remaining);
break;
};
let start = cursor + relative;
output.push_str(&input[cursor..start + needle_len]);
output.push_str(replacement);
cursor = start + needle_len;
while cursor < input.len() && !is_url_delimiter(input.as_bytes()[cursor]) {
cursor += 1;
}
}
output
}
fn closest_match(
left: Option<usize>,
left_len: usize,
right: Option<usize>,
right_len: usize,
) -> Option<(usize, usize)> {
match (left, right) {
(Some(left), Some(right)) if left <= right => Some((left, left_len)),
(Some(_left), Some(right)) => Some((right, right_len)),
(Some(left), None) => Some((left, left_len)),
(None, Some(right)) => Some((right, right_len)),
(None, None) => None,
}
}
fn normalize_cookie_tokens(input: &str) -> String {
let mut value = replace_prefixed_value(input, "admin_session=", "__SESSION__");
value = replace_prefixed_value(&value, "session=", "__SESSION__");
value = replace_prefixed_value(&value, "csrf_token=", "__CSRF_COOKIE__");
value = replace_prefixed_value(&value, "oauth_state=", "__OAUTH_STATE__");
replace_prefixed_value(&value, "flash=", "__FLASH__")
}
fn replace_prefixed_value(input: &str, prefix: &str, replacement: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut cursor = 0;
while let Some(relative) = input[cursor..].find(prefix) {
let start = cursor + relative;
output.push_str(&input[cursor..start + prefix.len()]);
output.push_str(replacement);
cursor = start + prefix.len();
while cursor < input.len() && !is_cookie_delimiter(input.as_bytes()[cursor]) {
cursor += 1;
}
}
output.push_str(&input[cursor..]);
output
}
fn is_url_delimiter(byte: u8) -> bool {
matches!(
byte,
b'&' | b'"' | b'\'' | b'<' | b'>' | b')' | b' ' | b'\n' | b'\t'
)
}
fn is_cookie_delimiter(byte: u8) -> bool {
matches!(byte, b';' | b'"' | b'\'' | b' ' | b'\n' | b'\t')
}
-10
View File
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod api_fixtures;
pub mod capture;
pub mod html_normalizer;
pub mod rust_server;
pub const TEST_ADMIN_SECRET: &str = "test-admin-secret";
pub const TEST_ADMIN_USER_ID: &str = "1130650140672000000";
pub const TEST_ACCESS_TOKEN: &str = "parity-access-token";
-88
View File
@@ -1,88 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::TEST_ADMIN_SECRET;
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
};
use std::time::{Duration, Instant};
use tokio::{net::TcpListener, task::JoinHandle, time::sleep};
pub struct RunningRustAdmin {
base_url: String,
handle: JoinHandle<()>,
}
impl RunningRustAdmin {
pub fn base_url(&self) -> &str {
&self.base_url
}
}
impl Drop for RunningRustAdmin {
fn drop(&mut self) {
self.handle.abort();
}
}
pub async fn start(api_endpoint: &str) -> Result<RunningRustAdmin, String> {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|error| format!("failed to bind Rust admin server: {error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("failed to read Rust admin address: {error}"))?
.port();
let base_url = format!("http://127.0.0.1:{port}");
let config = admin_config(port, api_endpoint, &base_url);
let router = build_router(config);
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
wait_for_health(&base_url).await?;
Ok(RunningRustAdmin { base_url, handle })
}
fn admin_config(port: u16, api_endpoint: &str, admin_endpoint: &str) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: "127.0.0.1".to_owned(),
port,
secret_key_base: TEST_ADMIN_SECRET.to_owned(),
base_path: String::new(),
api_endpoint: api_endpoint.to_owned(),
media_endpoint: format!("{api_endpoint}/media"),
static_cdn_endpoint: "https://static.example.test".to_owned(),
reports_bucket_origin: "https://reports.example.test".to_owned(),
admin_endpoint: admin_endpoint.to_owned(),
web_app_endpoint: "http://127.0.0.1:8088".to_owned(),
oauth_client_id: "1234567890123456789".to_owned(),
oauth_client_secret: "test-admin-oauth-secret".to_owned(),
oauth_redirect_uri: format!("{admin_endpoint}/oauth2_callback"),
build_version: "parity".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}
async fn wait_for_health(base_url: &str) -> Result<(), String> {
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("failed to build health client: {error}"))?;
let deadline = Instant::now() + Duration::from_secs(30);
let url = format!("{}/_health", base_url.trim_end_matches('/'));
let mut last_error = String::new();
while Instant::now() < deadline {
match client.get(&url).send().await {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(response) => last_error = format!("health returned {}", response.status()),
Err(error) => last_error = error.to_string(),
}
sleep(Duration::from_millis(200)).await;
}
Err(format!("timed out waiting for {url}: {last_error}"))
}
+1 -82
View File
@@ -11,7 +11,7 @@ use axum::{
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
config::{AdminConfig, ProxyConfig},
session,
};
use serde_json::{Value, json};
@@ -31,30 +31,6 @@ struct MockApi {
requests: Arc<Mutex<Vec<String>>>,
}
#[tokio::test]
async fn creating_a_reset_link_shows_the_url_once_with_a_copy_button() {
let app = setup(true, "username", vec!["*"]).await;
let csrf_token = csrf_token(&app).await;
let (status, body) = post_form(
&app,
&format!("/users/{TARGET_ID}?action=create_password_reset_link&tab=account"),
&format!("_csrf={csrf_token}"),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(app.saw(&format!(
"POST /admin/users/{TARGET_ID}/password-reset-link"
)));
assert!(body.contains("Copy this link now. It is shown only once."));
assert!(body.contains(&format!(r#"value="{RESET_URL}""#)));
assert!(body.contains(&format!(r#"data-copy-value="{RESET_URL}""#)));
assert!(body.contains("Copy Link"));
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Create Password Reset Link"));
assert!(!page.contains(RESET_URL));
}
#[tokio::test]
async fn an_htmx_reset_link_request_gets_only_the_result_fragment() {
let app = setup(true, "username", vec!["*"]).await;
@@ -133,16 +109,6 @@ async fn the_revoke_recovery_kit_action_needs_its_acl_and_a_username_instance()
assert!(!page.contains("Revoke Recovery Kit"));
}
#[tokio::test]
async fn username_instances_hide_email_actions_on_the_account_tab() {
let app = setup(true, "username", vec!["*"]).await;
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Create Password Reset Link"));
assert!(!page.contains("Send Password Reset"));
assert!(!page.contains("Change Email"));
assert!(!page.contains("Verify Email"));
}
#[tokio::test]
async fn the_reset_link_action_needs_its_acl() {
let app = setup(true, "username", vec!["admin:authenticate", "user:lookup"]).await;
@@ -152,52 +118,6 @@ async fn the_reset_link_action_needs_its_acl() {
assert!(!page.contains("Send Password Reset"));
}
#[tokio::test]
async fn email_instances_keep_the_email_actions() {
let app = setup(true, "email", vec!["*"]).await;
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Send Password Reset"));
assert!(page.contains("Change Email"));
assert!(page.contains("Verify Email"));
assert!(!page.contains("Create Password Reset Link"));
}
#[tokio::test]
async fn the_email_ban_notice_stays_after_a_ban_action_on_a_username_instance() {
let notice = "Accounts have no email address, so email bans have no effect.";
let username = setup(true, "username", vec!["*"]).await;
let username_csrf = csrf_token(&username).await;
let (status, body) = post_form(
&username,
"/email-bans?action=ban",
&format!("_csrf={username_csrf}&email="),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("Value is required"));
assert!(body.contains(notice));
let email = setup(true, "email", vec!["*"]).await;
let email_csrf = csrf_token(&email).await;
let (_, body) = post_form(
&email,
"/email-bans?action=ban",
&format!("_csrf={email_csrf}&email="),
)
.await;
assert!(body.contains("Value is required"));
assert!(!body.contains(notice));
}
#[tokio::test]
async fn hosted_admin_never_asks_discovery_for_the_sign_in_method() {
let app = setup(false, "username", vec!["*"]).await;
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Send Password Reset"));
assert!(!page.contains("Create Password Reset Link"));
assert!(!app.saw("GET /.well-known/fluxer"));
}
struct TestApp {
router: Router,
session_cookie: String,
@@ -423,7 +343,6 @@ fn user(id: &str, username: &str, acls: &[&str]) -> Value {
fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
-242
View File
@@ -1,242 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![recursion_limit = "256"]
use axum::{
Json, Router,
body::{Body, to_bytes},
extract::State,
http::{Method, Request, StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
};
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "username-tags-test-secret";
const ADMIN_ID: &str = "1500000000000000000";
const TARGET_ID: &str = "1500000000000000042";
const DISCRIMINATOR_INPUT: &str = r#"name="discriminator""#;
#[derive(Clone)]
struct MockApi {
account_identity: &'static str,
unique_usernames: bool,
target: Value,
}
#[tokio::test]
async fn username_instances_show_humans_without_a_tag() {
let page = account_page(true, "username", user(TARGET_ID, "member", 0, false)).await;
assert!(page.contains(r#"<p class="break-words text-sm text-neutral-500">member</p>"#));
assert!(page.contains(r#"<div class="truncate text-neutral-500 text-xs">lilith</div>"#));
assert!(!page.contains("member#0000"));
assert!(!page.contains("lilith#0000"));
assert!(!page.contains(DISCRIMINATOR_INPUT));
}
#[tokio::test]
async fn email_instances_with_random_tags_show_tags_and_allow_tag_changes() {
let page =
account_page_with(true, "email", false, user(TARGET_ID, "member", 1234, false)).await;
assert!(page.contains("member#1234"));
assert!(page.contains(DISCRIMINATOR_INPUT));
}
#[tokio::test]
async fn email_instances_with_no_tags_show_humans_without_a_tag() {
let page = account_page_with(true, "email", true, user(TARGET_ID, "member", 0, false)).await;
assert!(page.contains(r#"<p class="break-words text-sm text-neutral-500">member</p>"#));
assert!(!page.contains("member#0000"));
assert!(!page.contains("lilith#0000"));
assert!(!page.contains(DISCRIMINATOR_INPUT));
assert!(page.contains("Send Password Reset"));
}
#[tokio::test]
async fn username_instances_never_show_tags_even_if_told_random() {
let page =
account_page_with(true, "username", false, user(TARGET_ID, "member", 0, false)).await;
assert!(!page.contains("member#0000"));
assert!(!page.contains(DISCRIMINATOR_INPUT));
}
#[tokio::test]
async fn username_instances_keep_bot_tags() {
let page = account_page(true, "username", user(TARGET_ID, "helper", 4363, true)).await;
assert!(page.contains("helper#4363"));
assert!(page.contains(DISCRIMINATOR_INPUT));
}
#[tokio::test]
async fn email_instances_keep_the_zero_tag() {
let page = account_page(true, "email", user(TARGET_ID, "member", 0, false)).await;
assert!(page.contains("member#0000"));
assert!(page.contains("lilith#0000"));
assert!(page.contains(DISCRIMINATOR_INPUT));
}
#[tokio::test]
async fn hosted_admin_keeps_the_zero_tag() {
let page = account_page(false, "username", user(TARGET_ID, "member", 0, false)).await;
assert!(page.contains("member#0000"));
assert!(page.contains(DISCRIMINATOR_INPUT));
}
async fn account_page(self_hosted: bool, account_identity: &'static str, target: Value) -> String {
account_page_with(
self_hosted,
account_identity,
account_identity == "username",
target,
)
.await
}
async fn account_page_with(
self_hosted: bool,
account_identity: &'static str,
unique_usernames: bool,
target: Value,
) -> String {
let api_endpoint = spawn_mock_api(MockApi {
account_identity,
unique_usernames,
target,
})
.await;
let router = build_router(test_config(api_endpoint, self_hosted));
let session_value = session::create_session(ADMIN_ID, "test-token", SECRET_KEY);
let response = router
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/users/{TARGET_ID}?tab=account"))
.header(
header::COOKIE,
format!("{}={session_value}", session::SESSION_COOKIE_NAME),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn spawn_mock_api(mock: MockApi) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, Router::new().fallback(mock_api).with_state(mock))
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(State(mock): State<MockApi>, method: Method, uri: Uri) -> Response {
let target_user = format!("/admin/users/{TARGET_ID}");
let target_sessions = format!("{target_user}/sessions");
let target_credentials = format!("{target_user}/webauthn-credentials");
match (method, uri.path()) {
(Method::GET, "/admin/users/@me") => Json(json!({
"user": user(ADMIN_ID, "lilith", 0, false)
}))
.into_response(),
(Method::GET, "/.well-known/fluxer") => Json(json!({
"features": {
"premium_enabled": false,
"account_identity": mock.account_identity,
"tag_style": if mock.unique_usernames { "none" } else { "random" }
}
}))
.into_response(),
(Method::GET, p) if p == target_user => {
Json(json!({ "users": [mock.target] })).into_response()
}
(Method::GET, p) if p == target_sessions => Json(json!({ "sessions": [] })).into_response(),
(Method::GET, p) if p == target_credentials => Json(json!([])).into_response(),
_ => (
StatusCode::NOT_FOUND,
Json(json!({ "message": "not found" })),
)
.into_response(),
}
}
fn user(id: &str, username: &str, discriminator: u16, bot: bool) -> Value {
json!({
"id": id,
"username": username,
"discriminator": discriminator,
"avatar": null,
"banner": null,
"email": null,
"email_verified": false,
"email_bounced": false,
"global_name": null,
"bio": null,
"pronouns": null,
"accent_color": null,
"date_of_birth": null,
"locale": "en-GB",
"acls": ["*"],
"traits": [],
"flags": "0",
"premium_flags": 0,
"bot": bot,
"system": false,
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"has_totp": false,
"authenticator_types": [],
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
})
}
fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
base_path: String::new(),
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
reports_bucket_origin: String::new(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
self_hosted,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}
@@ -11,7 +11,7 @@ use axum::{
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
config::{AdminConfig, ProxyConfig},
session,
};
use serde_json::{Value, json};
@@ -313,7 +313,6 @@ fn admin_user() -> Value {
fn test_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
-2
View File
@@ -64,8 +64,6 @@ export abstract class ICacheService {
abstract extendLock(key: string, token: string, ttlSeconds: number): Promise<boolean>;
abstract getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null>;
abstract publish(channel: string, message: string): Promise<void>;
abstract sadd(key: string, member: string, ttlSeconds?: number): Promise<void>;
@@ -226,15 +226,6 @@ export class InMemoryProvider extends ICacheService {
return true;
}
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
const entry = this.getValueEntry(key);
if (!entry) {
return null;
}
entry.expiresAt = Date.now() + newTtlSeconds * 1000;
return entry.value as T;
}
async publish(_channel: string, _message: string): Promise<void> {
return;
}
@@ -192,12 +192,6 @@ export class KVCacheProvider extends ICacheService {
return await this.client.extendLock(lockKey, token, ttlSeconds);
}
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
const value = await this.client.getex(key, newTtlSeconds);
if (value == null) return null;
return safeJsonParse<T>(value, this.logger);
}
async publish(channel: string, message: string): Promise<void> {
await this.client.publish(channel, message);
}
-10
View File
@@ -37,14 +37,12 @@ interface CassandraBatchOptions {
interface ICassandraClient {
connect(): Promise<void>;
shutdown(): Promise<void>;
isConnected(): boolean;
execute<P extends CassandraParams>(
query: PreparedQuery<P>,
options?: CassandraExecuteOptions,
): Promise<cassandra.types.ResultSet>;
batch(queries: Array<PreparedQuery>, options?: CassandraBatchOptions): Promise<void>;
getNativeClient(): cassandra.Client;
setLogger(logger: Logger): void;
}
interface DefaultClientState {
@@ -131,10 +129,6 @@ class CassandraClient implements ICassandraClient {
this.logger.info({}, 'Cassandra connection closed');
}
public isConnected(): boolean {
return this.client !== null;
}
public async execute<P extends CassandraParams>(
query: PreparedQuery<P>,
options: CassandraExecuteOptions = {},
@@ -160,10 +154,6 @@ class CassandraClient implements ICassandraClient {
}
return this.client;
}
public setLogger(logger: Logger): void {
this.logger = logger;
}
}
export async function initCassandra(config: CassandraConfig): Promise<void> {
@@ -1,394 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EmailI18nService, type IEmailI18nService} from '@pkgs/email/src/EmailI18nService';
import type {EmailConfig, EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
import {EmailService} from '@pkgs/email/src/EmailService';
import {TestEmailService} from '@pkgs/email/src/TestEmailService';
import {describe, expect, it} from 'vitest';
const CONFIG: EmailConfig = {
enabled: true,
fromEmail: '[email protected]',
fromName: 'Fluxer',
appBaseUrl: 'https://example.com',
termsUrl: 'https://example.com/terms',
guidelinesUrl: 'https://example.com/guidelines',
appealsEmail: '[email protected]',
safetyEmail: '[email protected]',
supportEmail: '[email protected]',
productName: 'Fluxer',
};
async function sendWith(config: EmailConfig): Promise<EmailMessage> {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
const service = new EmailService(config, new EmailI18nService(), provider);
await expect(service.sendEmailChangeNew('[email protected]', 'testuser', '123456', 'en-US')).resolves.toBe(true);
expect(sent).toHaveLength(1);
return sent[0];
}
describe('EmailService reply-to', () => {
it('sets the configured reply-to address on every message', async () => {
const message = await sendWith({...CONFIG, replyTo: '[email protected]'});
expect(message.replyTo).toBe('[email protected]');
expect(message.from).toEqual({email: '[email protected]', name: 'Fluxer'});
});
it.each([undefined, null, ''])('omits the reply-to address when it is %j', async (replyTo) => {
const message = await sendWith({...CONFIG, replyTo});
expect(message).not.toHaveProperty('replyTo');
});
});
function createCapturingServiceFor(config: EmailConfig): {service: EmailService; sent: Array<EmailMessage>} {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
return {service: new EmailService(config, new EmailI18nService(), provider), sent};
}
function createCapturingService(): {service: EmailService; sent: Array<EmailMessage>} {
return createCapturingServiceFor(CONFIG);
}
describe('EmailService report notices', () => {
it('sends a receipt with the report id and target kind', async () => {
const {service, sent} = createCapturingService();
await expect(service.sendReportReceivedEmail('[email protected]', '1234567890', 'guild', 'en-US')).resolves.toBe(
true,
);
expect(sent).toHaveLength(1);
expect(sent[0].to).toBe('[email protected]');
expect(sent[0].subject).toBe('We received your Fluxer report');
expect(sent[0].text).toContain('report about a community on Fluxer.');
expect(sent[0].text).toContain('Report ID: 1234567890');
});
it('sends the receipt in the requested locale', async () => {
const {service, sent} = createCapturingService();
await service.sendReportReceivedEmail('[email protected]', '1234567890', 'message', 'de');
expect(sent[0].subject).toBe('Wir haben deine Fluxer-Meldung erhalten');
expect(sent[0].text).toContain('Meldungs-ID: 1234567890');
});
it('sends a DSA decision notice with the public comment', async () => {
const {service, sent} = createCapturingService();
await expect(
service.sendDsaReportResolvedEmail('[email protected]', '1234567890', 'We removed the content.', 'en-US'),
).resolves.toBe(true);
expect(sent).toHaveLength(1);
expect(sent[0].to).toBe('[email protected]');
expect(sent[0].subject).toBe('We made a decision on your Fluxer report');
expect(sent[0].text).toContain('(ID: 1234567890)');
expect(sent[0].text).toContain('Response from the Safety Team:\nWe removed the content.');
expect(sent[0].text).toContain('Email [email protected] from this email address');
});
it('sends a generic DSA decision notice without a public comment', async () => {
const {service, sent} = createCapturingService();
await service.sendDsaReportResolvedEmail('[email protected]', '1234567890', '', 'en-US');
expect(sent[0].text).not.toContain('Response from the Safety Team');
expect(sent[0].text).not.toContain('\n\n\n');
expect(sent[0].text).toContain('Email [email protected] from this email address');
});
});
describe('TestEmailService report notices', () => {
it('records the receipt and the DSA decision notice', async () => {
const service = new TestEmailService();
await service.sendReportReceivedEmail('[email protected]', '1234567890', 'user', 'en-US');
await service.sendDsaReportResolvedEmail('[email protected]', '1234567890', '', 'en-US');
expect(service.listSentEmails().map(({to, type, metadata}) => ({to, type, metadata}))).toEqual([
{to: '[email protected]', type: 'report_received', metadata: {report_id: '1234567890', target_kind: 'user'}},
{
to: '[email protected]',
type: 'dsa_report_resolved',
metadata: {report_id: '1234567890', public_comment: ''},
},
]);
});
});
describe('EmailService enforcement notices', () => {
function capture(config: EmailConfig): {service: EmailService; sent: Array<EmailMessage>} {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
return {service: new EmailService(config, new EmailI18nService(), provider), sent};
}
async function sendBoth(config: EmailConfig, reason: string | null = 'Spam'): Promise<Array<string>> {
const {service, sent} = capture(config);
const until = new Date('2026-10-01T23:30:00Z');
await service.sendAccountTempBannedEmail('[email protected]', 'testuser', reason, 24, until, 'en-US');
await service.sendAccountScheduledForDeletionEmail('[email protected]', 'testuser', reason, until, 'en-US');
expect(sent).toHaveLength(2);
return sent.map((message) => message.text);
}
it('links the configured terms and guidelines', async () => {
for (const text of await sendBoth(CONFIG)) {
expect(text).toContain(
'Please review:\n- Terms of Service: https://example.com/terms\n- Community Guidelines: https://example.com/guidelines\n\n',
);
}
});
it('leaves out the review list when no policy page is configured', async () => {
for (const text of await sendBoth({...CONFIG, termsUrl: null, guidelinesUrl: null})) {
expect(text).not.toContain('Please review:');
expect(text).not.toContain('https://');
expect(text).not.toContain('\n\n\n');
expect(text).toContain('[email protected]');
}
});
it('lists only the configured page', async () => {
for (const text of await sendBoth({...CONFIG, termsUrl: null, guidelinesUrl: 'https://rules.example.org'})) {
expect(text).toContain('Please review:\n- Community Guidelines: https://rules.example.org\n\n');
expect(text).not.toContain('Terms of Service:');
}
for (const text of await sendBoth({...CONFIG, termsUrl: 'https://tos.example.org', guidelinesUrl: null})) {
expect(text).toContain('Please review:\n- Terms of Service: https://tos.example.org\n\n');
expect(text).not.toContain('Community Guidelines:');
}
});
it('states the reason as its own paragraph', async () => {
const [suspended, deletion] = await sendBoth(CONFIG);
expect(suspended).toContain(' UTC\n\nReason: Spam\n\nDuring this time,');
expect(deletion).toContain(' UTC\n\nReason: Spam\n\nThis is a serious enforcement action.');
for (const text of [suspended, deletion]) {
expect(text).not.toContain('\n\n\n');
}
});
it.each([null, '', ' '])('leaves no gap when the reason is %j', async (reason) => {
const withReason = await sendBoth(CONFIG);
const [suspended, deletion] = await sendBoth(CONFIG, reason);
expect(suspended).toContain(' UTC\n\nDuring this time,');
expect(deletion).toContain(' UTC\n\nThis is a serious enforcement action.');
for (const [index, text] of [suspended, deletion].entries()) {
expect(text).not.toContain('Reason:');
expect(text).not.toContain('\n\n\n');
expect(text.split('\n\n')).toHaveLength(withReason[index].split('\n\n').length - 1);
}
});
});
describe('EmailService contact addresses', () => {
const SELF_HOSTED: EmailConfig = {...CONFIG, appealsEmail: null, safetyEmail: null, supportEmail: null};
const DATE = new Date('2026-10-01T23:30:00Z');
async function sendAll(config: EmailConfig, locale: string): Promise<Record<string, string>> {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
const service = new EmailService(config, new EmailI18nService(), provider);
const to = '[email protected]';
await service.sendAccountTempBannedEmail(to, 'testuser', 'Spam', 24, DATE, locale);
await service.sendAccountScheduledForDeletionEmail(to, 'testuser', 'Spam', DATE, locale);
await service.sendScheduledDeletionNotification(to, 'testuser', DATE, 'Spam', locale);
await service.sendDsaReportResolvedEmail(to, '1234567890', 'We removed the content.', locale);
await service.sendReportResolvedEmail(to, 'testuser', '1234567890', 'We removed the content.', locale);
await service.sendAccountDeletionCancelledEmail(to, 'testuser', locale);
await service.sendAccountDeletionRequestedEmail(to, 'testuser', null, DATE, locale);
await service.sendAccountDeletionInactivityEmail(to, 'testuser', null, DATE, locale);
expect(sent).toHaveLength(8);
const [tempBan, deletion, deletionNotice, dsaResolved, resolved, cancelled, requested, inactivity] = sent.map(
(message) => message.text,
);
return {tempBan, deletion, deletionNotice, dsaResolved, resolved, cancelled, requested, inactivity};
}
it('names the hosted mailboxes in en-US', async () => {
const hosted = await sendAll(CONFIG, 'en-US');
expect(hosted.tempBan).toContain(
'Email [email protected] from this email address and clearly explain why you believe the decision was incorrect.',
);
expect(hosted.deletion).toContain(
'you have 60 days to submit an appeal. Email [email protected] from this email address.\n',
);
expect(hosted.deletionNotice).toContain(
'you can submit an appeal. Email [email protected] from this email address.\n',
);
expect(hosted.dsaResolved).toContain('Email [email protected] from this email address, include your report ID,');
expect(hosted.resolved).toContain('please contact [email protected].\n');
expect(hosted.cancelled).toContain('If you have any questions, contact [email protected].\n');
expect(hosted.requested).toContain('contact [email protected] from this email address before that date.\n');
expect(hosted.inactivity).toContain('contact [email protected] from this email address before that date.\n');
});
it('points at the instance administrators in en-US when no mailbox is configured', async () => {
const neutral = await sendAll(SELF_HOSTED, 'en-US');
expect(neutral.tempBan).toContain(
'you can submit an appeal. Contact the administrators of this instance and clearly explain why you believe the decision was incorrect.',
);
expect(neutral.deletion).toContain(
'you have 60 days to submit an appeal. Contact the administrators of this instance.\n',
);
expect(neutral.deletionNotice).toContain(
'you can submit an appeal. Contact the administrators of this instance.\n',
);
expect(neutral.dsaResolved).toContain(
'Contact the administrators of this instance, include your report ID, and explain why you think the decision is wrong.',
);
expect(neutral.resolved).toContain(
'If you have any questions or concerns about this outcome, please contact the administrators of this instance.\n',
);
expect(neutral.cancelled).toContain('If you have any questions, contact the administrators of this instance.\n');
expect(neutral.requested).toContain(
"Your account is locked until then. If you didn't request this, or you want to keep your account, contact the administrators of this instance before that date.\n",
);
expect(neutral.inactivity).toContain(
'If you want to keep your account, contact the administrators of this instance before that date.\n',
);
});
it.each([
['de', 'die Administratoren dieser Instanz', 'Sende eine E-Mail von dieser E-Mail-Adresse an [email protected]'],
['ja', 'このインスタンスの管理者', 'このメールアドレスから[email protected]までメールを送信'],
])('renders both variants in %s', async (locale, administrators, hostedAppeal) => {
const hosted = await sendAll(CONFIG, locale);
const neutral = await sendAll(SELF_HOSTED, locale);
expect(hosted.tempBan).toContain(hostedAppeal);
for (const [name, text] of Object.entries(hosted)) {
expect(text, name).toMatch(/(appeals|safety)@fluxer\.com/);
expect(text, name).not.toContain(administrators);
}
for (const [name, text] of Object.entries(neutral)) {
expect(text, name).toContain(administrators);
expect(text, name).not.toContain('@');
expect(text, name).not.toMatch(/\bnull\b/);
expect(text, name).not.toContain('{');
expect(text, name).not.toContain('\n\n\n');
expect(text.split('\n'), name).toHaveLength(hosted[name].split('\n').length);
}
});
});
describe('EmailService support contact', () => {
const SELF_HOSTED: EmailConfig = {...CONFIG, appealsEmail: null, safetyEmail: null, supportEmail: null};
const DATE = new Date('2026-10-01T23:30:00Z');
async function sendSupportEmails(config: EmailConfig, locale: string): Promise<Array<string>> {
const {service, sent} = createCapturingServiceFor(config);
const to = '[email protected]';
await service.sendGiftChargebackNotification(to, 'testuser', locale);
await service.sendHarvestCompletedEmail(to, 'testuser', 'https://example.com/d', 10, 1024, DATE, locale);
await service.sendInactivityWarningEmail(to, 'testuser', DATE, DATE, locale);
expect(sent).toHaveLength(3);
return sent.map((message) => message.text);
}
it('keeps the support team sentences on the hosted instance', async () => {
const [gift, harvest, inactivity] = await sendSupportEmails(CONFIG, 'en-US');
expect(gift).toContain(
'\n\nIf you think this is a mistake, please contact our support team and include any details you have about the gift code and when you redeemed it.\n\n',
);
expect(harvest).toContain(
"\n\nIf you didn't request this export, please change your password immediately and contact our support team.\n\n",
);
expect(inactivity).toContain("\n\nIf you've used Fluxer recently, please contact our support team right away.\n\n");
});
it('points at the instance administrators when the instance has no support mailbox', async () => {
const [gift, harvest, inactivity] = await sendSupportEmails({...SELF_HOSTED, productName: 'Example Chat'}, 'en-US');
expect(gift).toContain(
'\n\nIf you think this is a mistake, please contact the administrators of this instance and include any details you have about the gift code and when you redeemed it.\n\n',
);
expect(harvest).toContain(
"\n\nIf you didn't request this export, please change your password immediately and contact the administrators of this instance.\n\n",
);
expect(inactivity).toContain(
"\n\nIf you've used Example Chat recently, please contact the administrators of this instance right away.\n\n",
);
for (const text of [gift, harvest, inactivity]) {
expect(text).not.toMatch(/support/i);
}
});
it.each([
['de', 'unser Support-Team', 'die Administratoren dieser Instanz'],
['ja', 'サポートチーム', 'このインスタンスの管理者'],
])('renders both variants in %s', async (locale, supportTeam, administrators) => {
const hosted = await sendSupportEmails(CONFIG, locale);
const neutral = await sendSupportEmails(SELF_HOSTED, locale);
for (const [index, text] of hosted.entries()) {
expect(text).toContain(supportTeam);
expect(text).not.toContain(administrators);
expect(neutral[index]).toContain(administrators);
expect(neutral[index]).not.toContain(supportTeam);
expect(neutral[index].split('\n')).toHaveLength(text.split('\n').length);
}
});
});
describe('EmailService product name', () => {
async function sendPasswordReset(config: EmailConfig, locale: string): Promise<EmailMessage> {
const {service, sent} = createCapturingServiceFor(config);
await expect(service.sendPasswordResetEmail('[email protected]', 'testuser', 'token', locale)).resolves.toBe(true);
expect(sent).toHaveLength(1);
return sent[0];
}
it('names Fluxer on the hosted instance', async () => {
const message = await sendPasswordReset(CONFIG, 'en-US');
expect(message.subject).toBe('Reset your Fluxer password');
expect(message.text).toContain('Fluxer');
});
it.each(['en-US', 'de', 'ja'])('names the configured instance in the %s subject and body', async (locale) => {
const message = await sendPasswordReset({...CONFIG, productName: 'Example Chat'}, locale);
expect(message.subject).toContain('Example Chat');
expect(message.text).toContain('Example Chat');
expect(`${message.subject}\n${message.text}`).not.toContain('Fluxer');
});
it('uses the name of the config it was built with, so a renamed instance sends the new name', async () => {
const before = await sendPasswordReset({...CONFIG, productName: 'Example Chat'}, 'en-US');
const after = await sendPasswordReset({...CONFIG, productName: 'Renamed Chat'}, 'en-US');
expect(before.subject).toBe('Reset your Example Chat password');
expect(after.subject).toBe('Reset your Renamed Chat password');
expect(after.text).not.toContain('Example Chat');
});
it('passes the name to every template the service sends', async () => {
const names: Array<string> = [];
const i18n = new EmailI18nService();
const recording: IEmailI18nService = {
getTemplate: (key, locale, variables, productName) => {
names.push(productName);
return i18n.getTemplate(key, locale, variables, productName);
},
};
const service = new EmailService({...CONFIG, productName: 'Example Chat'}, recording, {
sendEmail: async () => true,
});
const date = new Date('2026-10-01T23:30:00Z');
const to = '[email protected]';
await service.sendEmailVerification(to, 'testuser', 'token', 'en-US');
await service.sendAccountTempBannedEmail(to, 'testuser', 'Spam', 24, date, 'en-US');
await service.sendReportResolvedEmail(to, 'testuser', '1', 'Thanks', 'en-US');
await service.sendSelfDeletionScheduledEmail(to, 'testuser', date, 'en-US');
expect(names).toEqual(['Example Chat', 'Example Chat', 'Example Chat', 'Example Chat']);
});
});
@@ -1,44 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const {sendMail} = vi.hoisted(() => ({sendMail: vi.fn()}));
vi.mock('nodemailer', () => ({
default: {createTransport: () => ({sendMail, verify: vi.fn()})},
}));
const MESSAGE = {
to: '[email protected]',
from: {email: '[email protected]', name: 'Fluxer'},
subject: 'Subject',
text: 'Body',
};
function createProvider(): SmtpEmailProvider {
return new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'});
}
describe('SmtpEmailProvider', () => {
beforeEach(() => {
sendMail.mockReset();
sendMail.mockResolvedValue({});
});
it('passes the reply-to address to nodemailer', async () => {
await expect(createProvider().sendEmail({...MESSAGE, replyTo: '[email protected]'})).resolves.toBe(true);
expect(sendMail).toHaveBeenCalledWith({
to: '[email protected]',
from: 'Fluxer <[email protected]>',
replyTo: '[email protected]',
subject: 'Subject',
text: 'Body',
});
});
it('omits the reply-to address when the message has none', async () => {
await expect(createProvider().sendEmail(MESSAGE)).resolves.toBe(true);
expect(sendMail.mock.calls[0][0]).not.toHaveProperty('replyTo');
});
});
@@ -1,14 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {extractMessageTemplatePlaceholders} from '@fluxer/i18n/src/runtime/MessageCatalogTypes';
import {EmailI18nService} from '@pkgs/email/src/EmailI18nService';
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
import {EmailService} from '@pkgs/email/src/EmailService';
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
import {EMAIL_I18N_LOCALE_MESSAGES} from '@pkgs/email/src/email_i18n/EmailI18nLocales';
import {EMAIL_I18N_MESSAGES} from '@pkgs/email/src/email_i18n/EmailI18nMessages';
import type {EmailLegalLinks, EmailTemplateVariables} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
import type {EmailTemplateVariables} from '@pkgs/email/src/email_i18n/EmailI18nTypes';
import type {EmailTemplateKey} from '@pkgs/email/src/email_i18n/EmailI18nTypes.generated';
import {describe, expect, it} from 'vitest';
@@ -100,31 +94,6 @@ const FIXTURE: {[K in EmailTemplateKey]: EmailTemplateVariables[K]} = {
unban_notification: {username: 'testuser', reason: 'Appeal accepted'},
};
const CONTACT_KEYS = [
'account_deletion_cancelled',
'account_deletion_scheduled_inactivity',
'account_deletion_scheduled_requested',
'account_scheduled_deletion',
'account_temp_banned',
'dsa_report_resolved',
'report_resolved',
'scheduled_deletion_notification',
] as const satisfies ReadonlyArray<EmailTemplateKey>;
const SUPPORT_KEYS = [
'gift_chargeback_notification',
'harvest_completed',
'inactivity_warning',
] as const satisfies ReadonlyArray<EmailTemplateKey>;
function renderBody<K extends EmailTemplateKey>(key: K, locale: string, variables: EmailTemplateVariables[K]): string {
const result = getEmailTemplate(key, locale, variables, 'Fluxer');
if (!result.ok) {
throw new Error(result.error.message);
}
return result.value.body;
}
describe('EmailI18n locale files', () => {
it.each(LOCALES)('%s loads without module errors', (locale) => {
const template = getEmailTemplate(
@@ -165,284 +134,4 @@ describe('EmailI18n locale files', () => {
expect(result.value.body, key).not.toContain('Coordinated Universal Time');
}
});
it('renders dates and times in UTC with a zone label', () => {
expect(renderBody('self_deletion_scheduled', 'en-US', {username: 'testuser', deletionDate: DATE})).toContain(
'Thursday, October 1, 2026 at 11:30 PM UTC',
);
});
it.each(['account_deletion_scheduled_requested', 'account_deletion_scheduled_inactivity'] as const)(
'%s makes no enforcement claim',
(key) => {
const body = renderBody(key, 'en-US', {...FIXTURE[key], reason: 'Some reason'});
expect(body).not.toContain('Terms of Service');
expect(body.toLowerCase()).not.toContain('appeal');
expect(body).toContain('Reason: Some reason');
},
);
it.each(['account_deletion_scheduled_requested', 'account_deletion_scheduled_inactivity'] as const)(
'%s leaves no gap without a reason',
(key) => {
const body = renderBody(key, 'en-US', {...FIXTURE[key], reason: null});
expect(body).not.toContain('Reason:');
expect(body).not.toContain('\n\n\n');
},
);
it.each(['en-US', ...LOCALES])('%s names each reported target kind in the receipt', (locale) => {
const bodies = (['message', 'user', 'guild'] as const).map((targetKind) =>
renderBody('report_received', locale, {reportId: '1234567890', targetKind}),
);
expect(new Set(bodies).size).toBe(3);
for (const body of bodies) {
expect(body).toContain('1234567890');
expect(body).not.toContain('targetKind');
}
});
it('renders the receipt for each target kind', () => {
expect(renderBody('report_received', 'en-US', {reportId: '1', targetKind: 'message'})).toContain(
'report about a message on Fluxer.',
);
expect(renderBody('report_received', 'en-US', {reportId: '1', targetKind: 'user'})).toContain(
'report about an account on Fluxer.',
);
expect(renderBody('report_received', 'en-US', {reportId: '1', targetKind: 'guild'})).toContain(
'report about a community on Fluxer.',
);
});
it.each(['en-US', ...LOCALES])('%s tells a DSA reporter how to challenge the decision', (locale) => {
const withComment = renderBody('dsa_report_resolved', locale, {
reportId: '1234567890',
publicComment: 'We removed the content.',
hasComment: 'yes',
appeals_email: '[email protected]',
});
const withoutComment = renderBody('dsa_report_resolved', locale, {
reportId: '1234567890',
publicComment: '',
hasComment: 'no',
appeals_email: '[email protected]',
});
for (const body of [withComment, withoutComment]) {
expect(body).toContain('1234567890');
expect(body).toContain('[email protected]');
expect(body).toContain('60');
expect(body).not.toContain('\n\n\n');
}
expect(withComment).toContain('We removed the content.');
expect(withComment.split('\n\n')).toHaveLength(withoutComment.split('\n\n').length + 1);
});
it.each(['en-US', ...LOCALES])('%s lists only the configured policy links in enforcement notices', (locale) => {
const terms = 'https://example.com/terms';
const guidelines = 'https://example.com/guidelines';
for (const key of ['account_temp_banned', 'account_scheduled_deletion'] as const) {
const render = (termsUrl: string | null, guidelinesUrl: string | null, legalLinks: EmailLegalLinks) =>
renderBody(key, locale, {...FIXTURE[key], termsUrl, guidelinesUrl, legalLinks});
const both = render(terms, guidelines, 'both');
const termsOnly = render(terms, null, 'terms');
const guidelinesOnly = render(null, guidelines, 'guidelines');
const none = render(null, null, 'none');
expect(both, key).toContain(terms);
expect(both, key).toContain(guidelines);
expect(termsOnly, key).toContain(terms);
expect(termsOnly, key).not.toContain(guidelines);
expect(guidelinesOnly, key).toContain(guidelines);
expect(guidelinesOnly, key).not.toContain(terms);
expect(none, key).not.toContain('https://');
const bullets = (body: string) => body.split('\n').filter((line) => line.startsWith('- ')).length;
expect(bullets(both) - bullets(none), key).toBe(2);
expect(bullets(termsOnly) - bullets(none), key).toBe(1);
expect(bullets(guidelinesOnly) - bullets(none), key).toBe(1);
for (const body of [both, termsOnly, guidelinesOnly, none]) {
expect(body, key).not.toContain('\n\n\n');
expect(body, key).not.toContain('legalLinks');
expect(body, key).toContain('[email protected]');
}
expect(termsOnly.split('\n'), key).toHaveLength(both.split('\n').length - 1);
expect(guidelinesOnly.split('\n'), key).toHaveLength(both.split('\n').length - 1);
expect(none.split('\n\n'), key).toHaveLength(both.split('\n\n').length - 1);
}
});
it.each(['en-US', ...LOCALES])('%s leaves no gap in enforcement notices without a reason', (locale) => {
for (const key of ['account_temp_banned', 'account_scheduled_deletion'] as const) {
for (const legalLinks of ['both', 'none'] as const) {
const withReason = renderBody(key, locale, {...FIXTURE[key], legalLinks, reason: 'Repeated spam'});
const withoutReason = renderBody(key, locale, {...FIXTURE[key], legalLinks, reason: null});
expect(withReason, key).toContain('Repeated spam\n\n');
expect(withoutReason, key).not.toContain('Repeated spam');
for (const body of [withReason, withoutReason]) {
expect(body, key).not.toContain('\n\n\n');
expect(body, key).not.toContain('{');
}
expect(withoutReason.split('\n\n'), key).toHaveLength(withReason.split('\n\n').length - 1);
expect(withoutReason.split('\n'), key).toHaveLength(withReason.split('\n').length - 2);
}
}
});
it.each(['en-US', ...LOCALES])('%s names no mailbox when the instance has none', (locale) => {
const english = (key: (typeof CONTACT_KEYS)[number], variables: object) =>
renderBody(key, 'en-US', {...FIXTURE[key], ...variables} as never);
for (const key of CONTACT_KEYS) {
const hosted = renderBody(key, locale, FIXTURE[key] as never);
const neutral = renderBody(key, locale, {...FIXTURE[key], appeals_email: null, safety_email: null} as never);
expect(hosted, key).toMatch(/(appeals|safety)@fluxer\.com/);
expect(neutral, key).not.toContain('@');
expect(neutral, key).not.toMatch(/\bnull\b/);
expect(neutral, key).not.toContain('{');
expect(neutral, key).not.toContain('\n\n\n');
expect(neutral, key).not.toBe(hosted);
expect(neutral.split('\n'), key).toHaveLength(hosted.split('\n').length);
if (locale !== 'en-US' && locale !== 'en-GB') {
const neutralLine = neutral.split('\n').find((line, index) => line !== hosted.split('\n')[index]);
const englishNeutral = english(key, {appeals_email: null, safety_email: null}).split('\n');
expect(neutralLine, key).toBeDefined();
expect(englishNeutral, key).not.toContain(neutralLine);
expect(neutralLine, key).not.toContain('the administrators of this instance');
}
}
});
it.each(['en-US', ...LOCALES])('%s points at the instance administrators instead of a support team', (locale) => {
for (const key of SUPPORT_KEYS) {
const hosted = renderBody(key, locale, FIXTURE[key] as never);
const neutral = renderBody(key, locale, {...FIXTURE[key], support_email: null} as never);
const hostedLines = hosted.split('\n');
const neutralLines = neutral.split('\n');
expect(neutralLines, key).toHaveLength(hostedLines.length);
const changed = neutralLines.filter((line, index) => line !== hostedLines[index]);
expect(changed, key).toHaveLength(1);
expect(neutral, key).not.toMatch(/\bnull\b/);
expect(neutral, key).not.toContain('{');
expect(neutral, key).not.toContain('@');
expect(changed[0], key).not.toMatch(/support/i);
if (locale !== 'en-US' && locale !== 'en-GB') {
const englishNeutral = renderBody(key, 'en-US', {...FIXTURE[key], support_email: null} as never).split('\n');
expect(englishNeutral, key).not.toContain(changed[0]);
expect(changed[0], key).not.toContain('the administrators of this instance');
}
}
});
it.each(['en-US', ...LOCALES])('%s leaves no gap in deletion and unban notices without a reason', (locale) => {
for (const key of ['scheduled_deletion_notification', 'unban_notification'] as const) {
const withReason = renderBody(key, locale, {...FIXTURE[key], reason: 'Repeated spam'});
const withoutReason = renderBody(key, locale, {...FIXTURE[key], reason: null});
expect(withReason, key).toContain('Repeated spam\n\n');
expect(withoutReason, key).not.toContain('Repeated spam');
for (const body of [withReason, withoutReason]) {
expect(body, key).not.toContain('\n\n\n');
}
expect(withoutReason.split('\n\n'), key).toHaveLength(withReason.split('\n\n').length - 1);
}
});
it('greets a DSA reporter without a username', () => {
for (const key of ['report_received', 'dsa_report_resolved'] as const) {
const result = getEmailTemplate(key, 'en-US', FIXTURE[key], 'Fluxer');
expect(result.ok).toBe(true);
if (!result.ok) continue;
expect(result.value.body.startsWith('Hello,\n\n')).toBe(true);
expect(result.value.body).not.toContain('{');
}
});
it('renders a blank reason the same as no reason', async () => {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
const service = new EmailService(
{
enabled: true,
fromEmail: '[email protected]',
fromName: 'Fluxer',
appBaseUrl: 'https://example.com',
termsUrl: 'https://example.com/terms',
guidelinesUrl: 'https://example.com/guidelines',
appealsEmail: '[email protected]',
safetyEmail: '[email protected]',
supportEmail: '[email protected]',
productName: 'Fluxer',
},
new EmailI18nService(),
provider,
);
await service.sendUnbanNotification('[email protected]', 'testuser', ' ', 'en-US');
await service.sendUnbanNotification('[email protected]', 'testuser', null, 'en-US');
await service.sendAccountDeletionRequestedEmail('[email protected]', 'testuser', ' ', DATE, 'en-US');
await service.sendAccountDeletionRequestedEmail('[email protected]', 'testuser', null, DATE, 'en-US');
expect(sent).toHaveLength(4);
expect(sent[0].text).toBe(sent[1].text);
expect(sent[0].text).not.toContain('Reason:');
expect(sent[2].text).toBe(sent[3].text);
expect(sent[2].text).not.toContain('Reason:');
});
});
const HOSTED_RENDER_SHA256 = {
email_verification: {
'en-US': 'ea69bd530e1f9cf7cd04bc88d5c0a032069b2538bc5138eeee5bcea7dd21f539',
de: 'beb941104326bf73a523de0ccd33b78d3b531f461eea6ce1fae37a88634e69fa',
ja: 'e928c0d57baf6c1efd055d88661f6dc27f24119b0b972cafe614fed40663efb1',
},
password_reset: {
'en-US': 'b37a3491ef9126f251fd01fe2aa642891eb50aa56c23c36d0d62ccd0a9a7785d',
de: '148b9fbb52845befb1b9f24a063dafe2d5f000abc2104410d4e943ce56191917',
ja: 'd4198d68c126ad796954010431fe966b4df2b2fbabb9b4ddd5da0fbfd3b645a5',
},
ip_authorization: {
'en-US': '073ead7d3e270c886b44db16266f6cfb4a5b8b182d279a8bf17ddea49198b415',
de: 'd1c38f6a791a87379ca5b67e270d66e3741db314933b0b68c10bb9aab1781de1',
ja: 'adc16933f5efda8b176d7348942efaf6b2bff16bd09f14de9a48d727a76f011f',
},
gift_chargeback_notification: {
'en-US': '4ec08598e9087578ba52aa2490a1198dbfe68dd3bd496f4175b3b8ce533a01ac',
de: 'dd5a7f572a9063e26e5effa05193185286e28b5e6e256f3d0a98b97e7e31e952',
ja: 'c8d3a0fce144d278303f3d36c919d17bd60ab0738578f7e0f3e15e79bdb2bf19',
},
unban_notification: {
'en-US': '0cca736b44b8e1839bf3a1c9c8720c915ea80f40596801c57a9809c40c35c206',
de: '926e3762019888815da2ce7c70e94661878b9fba417bf0ffb11836abb2b74904',
ja: 'f5546b66f61d992aff2367c7b930d7683cd7a37891a3c2a069a8de2769113bd6',
},
} as const satisfies Partial<Record<EmailTemplateKey, Record<'en-US' | 'de' | 'ja', string>>>;
function renderEmail(key: EmailTemplateKey, locale: string, productName: string): {subject: string; body: string} {
const result = getEmailTemplate(key, locale, FIXTURE[key], productName);
if (!result.ok) {
throw new Error(result.error.message);
}
return result.value;
}
describe('EmailI18n product name', () => {
it('renders the hosted name byte for byte as before the name became a parameter', () => {
for (const [key, locales] of Object.entries(HOSTED_RENDER_SHA256)) {
for (const [locale, expected] of Object.entries(locales)) {
const {subject, body} = renderEmail(key as EmailTemplateKey, locale, 'Fluxer');
const actual = createHash('sha256').update(`${subject}\n${body}`).digest('hex');
expect(actual, `${key} ${locale}`).toBe(expected);
}
}
});
it.each(['en-US', ...LOCALES])('%s names the instance and never Fluxer in every template', (locale) => {
for (const key of TEMPLATE_KEYS) {
const {subject, body} = renderEmail(key, locale, 'Example Chat');
const rendered = `${subject}\n${body}`;
expect(rendered, key).toContain('Example Chat');
expect(rendered, key).not.toContain('Fluxer');
expect(rendered, key).not.toContain('{product_name}');
}
});
it.each(['en-US', ...LOCALES])('%s differs from the hosted render only by the name', (locale) => {
for (const key of TEMPLATE_KEYS) {
const hosted = renderEmail(key, locale, 'Fluxer');
const selfHosted = renderEmail(key, locale, 'Example Chat');
expect(selfHosted.subject.replaceAll('Example Chat', 'Fluxer'), key).toBe(hosted.subject);
expect(selfHosted.body.replaceAll('Example Chat', 'Fluxer'), key).toBe(hosted.body);
}
});
it('prints a name with message syntax characters as written', () => {
const {subject, body} = renderEmail('password_reset', 'en-US', "Bob's {Chat} #1");
expect(`${subject}\n${body}`).toContain("Bob's {Chat} #1");
});
});
@@ -1,9 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
import {createTestServer, readRequestBody, type TestServer} from '@pkgs/http_client/src/__tests__/TestHttpServer';
import {createTestServer, type TestServer} from '@pkgs/http_client/src/__tests__/TestHttpServer';
import {createHttpClient} from '@pkgs/http_client/src/HttpClient';
import type {HttpClientMetrics, HttpClientTelemetry} from '@pkgs/http_client/src/HttpClientTelemetryTypes';
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@pkgs/http_client/src/HttpClientTypes';
import {HttpError} from '@pkgs/http_client/src/HttpError';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
@@ -21,329 +17,8 @@ describe('HttpClient', () => {
await testServer.close();
await redirectServer.close();
});
describe('createHttpClient', () => {
it('creates an HTTP client with the provided user agent', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(req.headers['user-agent'] ?? '');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe(TEST_USER_AGENT);
});
});
describe('sendRequest', () => {
describe('basic requests', () => {
it('sends a GET request by default', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('sends a POST request with body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const requestBody = {message: 'Hello, World!'};
testServer.setHandler(async (req, res) => {
const body = await readRequestBody(req);
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method, receivedBody: JSON.parse(body)}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: requestBody,
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
expect(json.receivedBody).toEqual(requestBody);
});
it('sends URLSearchParams bodies as form-encoded payloads', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const requestBody = new URLSearchParams({
grant_type: 'authorization_code',
code: 'test-code',
});
testServer.setHandler(async (req, res) => {
const body = await readRequestBody(req);
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(
JSON.stringify({
method: req.method,
receivedBody: body,
contentType: req.headers['content-type'],
}),
);
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: requestBody,
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
expect(json.receivedBody).toBe(requestBody.toString());
expect(json.contentType).toContain('application/x-www-form-urlencoded');
});
it('sends a HEAD request', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain', 'X-Custom-Header': 'test-value'});
res.end();
});
const response = await client.sendRequest({
url: testServer.url,
method: 'HEAD',
});
expect(response.status).toBe(200);
expect(response.headers.get('X-Custom-Header')).toBe('test-value');
});
});
describe('headers', () => {
it('sends default headers including user agent', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(
JSON.stringify({
userAgent: req.headers['user-agent'],
accept: req.headers['accept'],
cacheControl: req.headers['cache-control'],
pragma: req.headers['pragma'],
}),
);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(json.userAgent).toBe(TEST_USER_AGENT);
expect(json.accept).toBe('*/*');
expect(json.cacheControl).toBe('no-cache, no-store, must-revalidate');
expect(json.pragma).toBe('no-cache');
});
it('allows custom headers to override defaults', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(
JSON.stringify({
accept: req.headers['accept'],
customHeader: req.headers['x-custom-header'],
}),
);
});
const response = await client.sendRequest({
url: testServer.url,
headers: {
Accept: 'application/json',
'X-Custom-Header': 'custom-value',
},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(json.accept).toBe('application/json');
expect(json.customHeader).toBe('custom-value');
});
it('normalizes response headers correctly', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
'X-Single-Value': 'single',
});
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.headers.get('content-type')).toBe('text/plain');
expect(response.headers.get('x-single-value')).toBe('single');
});
});
describe('status codes', () => {
it('returns 200 status for successful requests', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(200);
});
it('returns 404 status for not found', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(404);
res.end('Not Found');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(404);
});
it('returns 500 status for server errors', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(500);
res.end('Internal Server Error');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(500);
});
it('handles 304 Not Modified without following redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(HttpStatus.NOT_MODIFIED);
res.end();
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(HttpStatus.NOT_MODIFIED);
expect(response.url).toBe(new URL(testServer.url).href);
});
});
describe('redirects', () => {
it('follows 301 redirect', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/target`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({path: req.url, method: req.method}));
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(response.url).toBe(`${redirectServer.url}/target`);
expect(json.path).toBe('/target');
});
it('follows 301 redirect and changes POST to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {value: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('follows 302 redirect', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/found`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(req.url ?? '');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(response.status).toBe(200);
expect(body).toBe('/found');
});
it('follows 302 redirect preserving PATCH', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/found`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'PATCH',
body: {value: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('PATCH');
});
it('follows 303 redirect and changes method to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(303, {Location: `${redirectServer.url}/see-other`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('drops content headers when redirect switches to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let contentType: string | undefined;
let contentLength: string | undefined;
testServer.setHandler((_req, res) => {
res.writeHead(303, {Location: `${redirectServer.url}/see-other`});
res.end();
});
redirectServer.setHandler((req, res) => {
contentType = req.headers['content-type'] as string | undefined;
contentLength = req.headers['content-length'] as string | undefined;
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
expect(contentType).toBeUndefined();
expect(contentLength).toBeUndefined();
});
it('drops content headers when 301 redirect switches to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let contentType: string | undefined;
let contentLength: string | undefined;
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
res.end();
});
redirectServer.setHandler((req, res) => {
contentType = req.headers['content-type'] as string | undefined;
contentLength = req.headers['content-length'] as string | undefined;
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
expect(contentType).toBeUndefined();
expect(contentLength).toBeUndefined();
});
it('strips sensitive headers on cross-origin redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const secretToken = 'Bearer ultra-secret-token';
@@ -399,65 +74,6 @@ describe('HttpClient', () => {
});
expect(receivedAuthorization).toBe(secretToken);
});
it('follows 307 redirect preserving method', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(307, {Location: `${redirectServer.url}/temp`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
});
it('follows 308 redirect preserving method', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(308, {Location: `${redirectServer.url}/permanent`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
});
it('follows multiple redirects up to max limit', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let redirectCount = 0;
testServer.setHandler((_req, res) => {
redirectCount++;
if (redirectCount < 5) {
res.writeHead(302, {Location: `${testServer.url}/redirect${redirectCount}`});
res.end();
} else {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({redirectCount}));
}
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.redirectCount).toBe(5);
});
it('throws error when exceeding max redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
@@ -468,35 +84,6 @@ describe('HttpClient', () => {
'Maximum number of redirects (5) exceeded',
);
});
it('throws error when redirect has no Location header', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302);
res.end();
});
await expect(client.sendRequest({url: testServer.url})).rejects.toThrow(
'Received redirect response without Location header',
);
});
it('handles relative redirect URLs', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let requestCount = 0;
testServer.setHandler((req, res) => {
requestCount++;
if (requestCount === 1) {
res.writeHead(302, {Location: '/relative-path'});
res.end();
} else {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({path: req.url}));
}
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.path).toBe('/relative-path');
});
it('validates redirect targets with request URL policy before following', async () => {
const validationCalls: Array<RequestUrlValidationContext> = [];
const requestUrlPolicy: RequestUrlPolicy = {
@@ -526,15 +113,6 @@ describe('HttpClient', () => {
});
});
describe('timeout', () => {
it('uses default timeout of 30 seconds', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(200);
});
it('respects custom timeout', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
@@ -551,248 +129,5 @@ describe('HttpClient', () => {
).rejects.toThrow();
});
});
describe('abort signal', () => {
it('aborts request when signal is triggered', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const controller = new AbortController();
testServer.setHandler((_req, res) => {
setTimeout(() => {
res.writeHead(200);
res.end('OK');
}, 1000);
});
const requestPromise = client.sendRequest({
url: testServer.url,
signal: controller.signal,
});
setTimeout(() => controller.abort(), 50);
await expect(requestPromise).rejects.toThrow();
});
it('handles pre-aborted signal', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const controller = new AbortController();
controller.abort('Pre-aborted');
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await expect(
client.sendRequest({
url: testServer.url,
signal: controller.signal,
}),
).rejects.toThrow();
});
});
describe('error handling', () => {
it('throws HttpError for connection refused', async () => {
const client = createHttpClient(TEST_USER_AGENT);
try {
await client.sendRequest({url: 'http://127.0.0.1:1'});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(HttpError);
const httpError = error as HttpError;
expect(httpError.isExpected).toBe(true);
}
});
it('throws HttpError for DNS resolution failure', async () => {
const client = createHttpClient(TEST_USER_AGENT);
try {
await client.sendRequest({url: 'http://this-domain-does-not-exist-12345.invalid'});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(HttpError);
const httpError = error as HttpError;
expect(httpError.isExpected).toBe(true);
}
});
});
});
describe('streamToString', () => {
it('converts response stream to string', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World!');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe('Hello, World!');
});
it('handles empty response body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(204);
res.end();
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe('');
});
it('handles large response body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const largeContent = 'x'.repeat(1024 * 1024);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(largeContent);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body.length).toBe(largeContent.length);
});
it('handles UTF-8 content correctly', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const unicodeContent = 'Hello, World! Emoji: \u{1F600} Chinese: \u4E2D\u6587 Arabic: \u0639\u0631\u0628\u064A';
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
res.end(unicodeContent);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe(unicodeContent);
});
});
describe('telemetry', () => {
it('records metrics for successful requests', async () => {
const recordedMetrics: Array<{
type: string;
name: string;
dimensions?: Record<string, string>;
}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
const latencyMetric = recordedMetrics.find((m) => m.name === 'http_client.latency');
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
const responseMetric = recordedMetrics.find((m) => m.name === 'http_client.response');
expect(latencyMetric).toBeDefined();
expect(latencyMetric?.dimensions?.service).toBe('test-service');
expect(latencyMetric?.dimensions?.method).toBe('GET');
expect(requestMetric).toBeDefined();
expect(requestMetric?.dimensions?.service).toBe('test-service');
expect(requestMetric?.dimensions?.status).toBe('2xx');
expect(responseMetric).toBeDefined();
expect(responseMetric?.dimensions?.service).toBe('test-service');
expect(responseMetric?.dimensions?.status_code).toBe('2xx');
});
it('records metrics for error responses', async () => {
const recordedMetrics: Array<{
type: string;
name: string;
dimensions?: Record<string, string>;
}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(404);
res.end('Not Found');
});
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
expect(requestMetric?.dimensions?.status).toBe('404');
});
it('records metrics for network errors', async () => {
const recordedMetrics: Array<{
type: string;
name: string;
dimensions?: Record<string, string>;
}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
try {
await client.sendRequest({url: 'http://127.0.0.1:1', serviceName: 'test-service'});
} catch {}
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
expect(requestMetric?.dimensions?.status).toBe('network_error');
});
it('uses default service name when not provided', async () => {
const recordedMetrics: Array<{
type: string;
name: string;
dimensions?: Record<string, string>;
}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({url: testServer.url});
const latencyMetric = recordedMetrics.find((m) => m.name === 'http_client.latency');
expect(latencyMetric?.dimensions?.service).toBe('unknown');
});
});
});
describe('HttpError', () => {
it('creates error with message', () => {
const error = new HttpError('Test error');
expect(error.message).toBe('Test error');
expect(error.name).toBe('HttpError');
expect(error.status).toBeUndefined();
expect(error.response).toBeUndefined();
expect(error.isExpected).toBe(false);
expect(error.errorType).toBeUndefined();
});
it('creates error with status code', () => {
const error = new HttpError('Not Found', 404);
expect(error.message).toBe('Not Found');
expect(error.status).toBe(404);
});
it('creates error with response', () => {
const response = new Response('Error body', {status: 500});
const error = new HttpError('Server Error', 500, response);
expect(error.status).toBe(500);
expect(error.response).toBe(response);
});
it('creates error with isExpected flag', () => {
const error = new HttpError('Expected error', 400, undefined, true);
expect(error.isExpected).toBe(true);
});
it('creates error with errorType', () => {
const error = new HttpError('Aborted', undefined, undefined, false, 'aborted');
expect(error.errorType).toBe('aborted');
});
it('is an instance of Error', () => {
const error = new HttpError('Test');
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(HttpError);
});
});
@@ -54,12 +54,3 @@ export async function createTestServer(): Promise<TestServer> {
});
});
}
export function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Array<Buffer> = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}
@@ -25,7 +25,6 @@ export interface IKVSubscription {
unsubscribe(...channels: Array<string>): Promise<void>;
quit(): Promise<void>;
disconnect(): Promise<void>;
removeAllListeners(event?: 'message' | 'error'): void;
}
export interface KVPurgeBatchResult {
@@ -78,22 +77,13 @@ export interface IKVProvider {
ltrim(key: string, start: number, stop: number): Promise<void>;
hset(key: string, field: string, value: string): Promise<number>;
hdel(key: string, ...fields: Array<string>): Promise<number>;
hget(key: string, field: string): Promise<string | null>;
hgetall(key: string): Promise<Record<string, string>>;
publish(channel: string, message: string): Promise<number>;
duplicate(): IKVSubscription;
acquireLock(key: string, token: string, ttlSeconds: number): Promise<boolean>;
releaseLock(key: string, token: string): Promise<boolean>;
extendLock(key: string, token: string, ttlSeconds: number): Promise<boolean>;
renewSnowflakeNode(key: string, instanceId: string, ttlSeconds: number): Promise<boolean>;
checkLeakyBucketLimit(key: string, limit: number, windowMs: number, cost: number): Promise<KVRateLimitResult>;
tryConsumeTokens(
key: string,
requested: number,
maxTokens: number,
refillRate: number,
refillIntervalMs: number,
): Promise<number>;
scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void>;
claimBulkDeletion(queueKey: string, member: string, maxScore: number, leaseScore: number): Promise<boolean>;
removeBulkDeletion(queueKey: string, secondaryKey: string, member?: string): Promise<boolean>;
@@ -109,5 +99,4 @@ export interface IKVProvider {
pipeline(): IKVPipeline;
multi(): IKVPipeline;
isClustered(): boolean;
health(): Promise<boolean>;
}
-96
View File
@@ -65,52 +65,6 @@ if redis.call('GET', KEYS[1]) == ARGV[1] then
end
return 0
`;
const RENEW_SNOWFLAKE_SCRIPT = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
return 1
end
return 0
`;
const TRY_CONSUME_TOKENS_SCRIPT = `
${DECODE_BUCKET_STATE_SCRIPT}
local key = KEYS[1]
local now = tonumber(ARGV[1])
local requested = tonumber(ARGV[2])
local maxTokens = tonumber(ARGV[3])
local refillRate = tonumber(ARGV[4])
local refillIntervalMs = tonumber(ARGV[5])
local data = redis.call('GET', key)
local tokens = maxTokens
local lastRefill = now
if data then
tokens, lastRefill = decodeBucketState(data, 'tokens', 'lastRefill')
end
local elapsed = now - lastRefill
if elapsed >= refillIntervalMs then
local intervals = math.floor(elapsed / refillIntervalMs)
local tokensToAdd = intervals * refillRate
if tokensToAdd > 0 then
tokens = math.min(maxTokens, tokens + tokensToAdd)
lastRefill = now
end
end
local consumed = 0
if tokens >= requested then
consumed = requested
tokens = tokens - requested
elseif tokens > 0 then
consumed = tokens
tokens = 0
end
redis.call('SET', key, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
return consumed
`;
const CHECK_LEAKY_BUCKET_LIMIT_SCRIPT = `
${DECODE_BUCKET_STATE_SCRIPT}
local key = KEYS[1]
@@ -324,15 +278,6 @@ export class KVClient implements IKVProvider {
this.client.disconnect(false);
}
async health(): Promise<boolean> {
try {
return (await this.execute('health', async () => this.client.ping())) === 'PONG';
} catch (error) {
this.logger.debug({error}, 'KV health check failed');
return false;
}
}
async get(key: string): Promise<string | null> {
return await this.execute('get', async () => this.client.get(key));
}
@@ -571,10 +516,6 @@ export class KVClient implements IKVProvider {
return await this.execute('hdel', async () => this.client.hdel(key, ...fields));
}
async hget(key: string, field: string): Promise<string | null> {
return await this.execute('hget', async () => this.client.hget(key, field));
}
async hgetall(key: string): Promise<Record<string, string>> {
return await this.execute('hgetall', async () => this.client.hgetall(key));
}
@@ -611,18 +552,6 @@ export class KVClient implements IKVProvider {
return parseIntegerDecision(result, 'extendLock');
}
async renewSnowflakeNode(key: string, instanceId: string, ttlSeconds: number): Promise<boolean> {
const result = await this.executeScript(
'renewSnowflakeNode',
RENEW_SNOWFLAKE_SCRIPT,
1,
key,
instanceId,
ttlSeconds,
);
return parseIntegerDecision(result, 'renewSnowflakeNode');
}
async checkLeakyBucketLimit(key: string, limit: number, windowMs: number, cost: number): Promise<KVRateLimitResult> {
const result = await this.executeJsonScript(
'checkLeakyBucketLimit',
@@ -637,31 +566,6 @@ export class KVClient implements IKVProvider {
return parseRateLimitResult(result);
}
async tryConsumeTokens(
key: string,
requested: number,
maxTokens: number,
refillRate: number,
refillIntervalMs: number,
): Promise<number> {
const now = Date.now();
const result = await this.executeScript(
'tryConsumeTokens',
TRY_CONSUME_TOKENS_SCRIPT,
1,
key,
now,
requested,
maxTokens,
refillRate,
refillIntervalMs,
);
if (!isNonNegativeSafeInteger(result) || result > requested) {
throw createInvalidResponseError('tryConsumeTokens', 'an integer token count within the requested amount');
}
return result;
}
async scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void> {
const result = await this.executeScript(
'scheduleBulkDeletion',
@@ -258,15 +258,6 @@ export class KVSubscription implements IKVSubscription {
}
client.disconnect(false);
}
removeAllListeners(event?: 'message' | 'error'): void {
if (!event || event === 'message') {
this.messageCallbacks.clear();
}
if (!event || event === 'error') {
this.errorCallbacks.clear();
}
}
}
function createRetryStrategy(): (times: number) => number {
@@ -115,24 +115,12 @@ describe('KVClient script execution', () => {
keyCount: 1,
run: async (client) => client.extendLock('lock:key', 'token', 30),
},
{
name: 'renewSnowflakeNode',
reply: 1,
keyCount: 1,
run: async (client) => client.renewSnowflakeNode('snowflake:1', 'instance', 30),
},
{
name: 'checkLeakyBucketLimit',
reply: RATE_LIMIT_REPLY,
keyCount: 1,
run: async (client) => client.checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1),
},
{
name: 'tryConsumeTokens',
reply: 2,
keyCount: 1,
run: async (client) => client.tryConsumeTokens('tokens:key', 2, 10, 1, 1000),
},
{
name: 'scheduleBulkDeletion',
reply: 1,
-5
View File
@@ -46,7 +46,6 @@ export interface PostgresQueryable {
export interface IPostgresClient extends PostgresQueryable {
connect(): Promise<void>;
shutdown(): Promise<void>;
isConnected(): boolean;
transaction<T>(fn: (client: PostgresQueryable) => Promise<T>): Promise<T>;
kvTable(): string;
}
@@ -215,10 +214,6 @@ class PostgresClient implements IPostgresClient {
await pool.end();
}
isConnected(): boolean {
return this.pool !== null && this.disconnection === null;
}
async query<T extends QueryResultRow = QueryResultRow>(
text: string,
values: Array<unknown> = [],
@@ -9,5 +9,4 @@ export interface IWorkerService<TTaskName extends string = string> {
options?: WorkerJobOptions,
): Promise<bigint>;
cancelJob(jobId: bigint): Promise<boolean>;
retryDeadLetterJob(jobId: bigint): Promise<boolean>;
}
-24
View File
@@ -37,24 +37,6 @@ afterAll(async () => {
});
describe('buildAPIServerOptions', () => {
test('starts the api on the shipped header and request timeouts', async () => {
const server = await listenWithEnv();
expect(server.headersTimeout).toBe(30_000);
expect(server.requestTimeout).toBe(120_000);
});
test('passes the operator header timeout from the environment into the server', async () => {
const server = await listenWithEnv({FLUXER_API_HEADERS_TIMEOUT_MS: '45000'});
expect(server.headersTimeout).toBe(45_000);
expect(server.requestTimeout).toBe(120_000);
});
test('passes the operator request timeout from the environment into the server', async () => {
const server = await listenWithEnv({FLUXER_API_REQUEST_TIMEOUT_MS: '600000'});
expect(server.headersTimeout).toBe(30_000);
expect(server.requestTimeout).toBe(600_000);
});
test('clamps a header timeout set above the request timeout', async () => {
const server = await listenWithEnv({
FLUXER_API_HEADERS_TIMEOUT_MS: '90000',
@@ -120,12 +102,6 @@ describe('buildAPIConfigFromMaster upload relay secret', () => {
buildAPIConfigFromMaster(withUploadRelaySecret(master, secret)).mediaProxy.uploadRelay.relaySecretBase64,
).toBe(secret);
});
it('reads the relay secret from the loaded config rather than the environment', () => {
expect(buildAPIConfigFromMaster(master).mediaProxy.uploadRelay.relaySecretBase64).toBe(
master.services.media_proxy.upload_relay.secret_base64,
);
});
});
describe('buildAPIConfigFromMaster stripe legacy prices', () => {
-34
View File
@@ -79,27 +79,6 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
return Array.from(normalized);
}
function mapApnsApps(
apps:
| Array<{
app_id?: string;
topic?: string;
environment?: 'production' | 'development';
}>
| undefined,
): APIConfig['push']['apns']['apps'] {
return (apps ?? []).map((app) => {
if (!app.app_id) {
throw new Error('FLUXER_PUSH_APNS_APPS contains an entry with no app_id');
}
return {
appId: app.app_id,
topic: app.topic,
environment: app.environment,
};
});
}
const TRUSTED_CALLER_MIN_KEY_LENGTH = 32;
function parseTrustedCaller(entry: unknown, index: number): TrustedCallerConfig {
@@ -397,11 +376,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
rpId: master.auth.passkeys.rp_id,
allowedOrigins: master.auth.passkeys.additional_allowed_origins,
},
vapid: {
publicKey: master.auth.vapid.public_key,
privateKey: master.auth.vapid.private_key,
email: master.auth.vapid.email,
},
bluesky: master.auth.bluesky as BlueskyOAuthConfig,
},
klipy: {
@@ -456,14 +430,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
push: {
publicVapidKey: master.auth.vapid.public_key,
apns: {
enabled: master.integrations.push.apns.enabled,
teamId: master.integrations.push.apns.team_id,
keyId: master.integrations.push.apns.key_id,
privateKey: master.integrations.push.apns.private_key,
privateKeyPath: master.integrations.push.apns.private_key_path,
apps: mapApnsApps(master.integrations.push.apns.apps),
},
},
appStore: {
enabled: master.integrations.app_store.enabled,
-42
View File
@@ -1,42 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {initializeFluxerErrorMap} from '@app/api/ZodErrorMap';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {beforeAll, describe, expect, it} from 'vitest';
import {z} from 'zod';
function firstIssueMessage(schema: z.ZodType, value: unknown): string | undefined {
const result = schema.safeParse(value);
return result.success ? undefined : result.error.issues[0]?.message;
}
describe('ZodErrorMap', () => {
beforeAll(() => {
initializeFluxerErrorMap();
});
it('maps a date below its minimum to INVALID_FORMAT', () => {
expect(
firstIssueMessage(z.date().min(new Date('2000-01-01T00:00:00.000Z')), new Date('1999-12-31T00:00:00.000Z')),
).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('maps a date above its maximum to INVALID_FORMAT', () => {
expect(
firstIssueMessage(z.date().max(new Date('2000-01-01T00:00:00.000Z')), new Date('2000-01-02T00:00:00.000Z')),
).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('maps both numeric bounds to INVALID_FORMAT', () => {
expect(firstIssueMessage(z.number().min(1), 0)).toBe(ValidationErrorCodes.INVALID_FORMAT);
expect(firstIssueMessage(z.number().max(1), 2)).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('maps a string longer than its maximum to CONTENT_EXCEEDS_MAX_LENGTH', () => {
expect(firstIssueMessage(z.string().max(1), 'ab')).toBe(ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH);
});
it('maps a string shorter than its minimum to INVALID_FORMAT', () => {
expect(firstIssueMessage(z.string().min(2), 'a')).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
});
@@ -60,25 +60,10 @@ function getEmailBlocklistKeys(email: string): Array<string> {
}
return keys;
}
const IS_PHRASE_BANNED_QUERY = BannedPhrases.select({
where: BannedPhrases.where.eq('phrase'),
});
const LOAD_ALL_BANNED_PHRASES_QUERY = BannedPhrases.select();
const IS_URL_BANNED_QUERY = BannedUrls.select({
where: BannedUrls.where.eq('url_canonical'),
});
const LOAD_ALL_BANNED_URLS_QUERY = BannedUrls.select();
const IS_URL_DOMAIN_BANNED_QUERY = BannedUrlDomains.select({
where: BannedUrlDomains.where.eq('domain'),
});
const LOAD_ALL_BANNED_URL_DOMAINS_QUERY = BannedUrlDomains.select();
const IS_FILE_SHA_BANNED_QUERY = BannedFileShas.select({
where: BannedFileShas.where.eq('sha256_hex'),
});
const LOAD_ALL_BANNED_FILE_SHAS_QUERY = BannedFileShas.select();
const IS_AVATAR_HASH_BANNED_QUERY = BannedAvatarHashes.select({
where: BannedAvatarHashes.where.eq('hash_short'),
});
const LOAD_ALL_BANNED_AVATAR_HASHES_QUERY = BannedAvatarHashes.select();
const LOAD_ALL_BANNED_PROFILE_SUBSTRINGS_QUERY = BannedProfileSubstrings.select();
const createListAllAuditLogsPaginatedQuery = (limit: number) =>
@@ -274,14 +259,6 @@ export class AdminRepository implements IAdminRepository {
return rows.map((row) => row.email_lower);
}
async isPhraseBanned(phrase: string): Promise<boolean> {
const phraseLower = canonicalizeStoredPhrase(phrase);
const result = await fetchOne<{
phrase: string;
}>(IS_PHRASE_BANNED_QUERY.bind({phrase: phraseLower}));
return !!result;
}
async banPhrase(phrase: string): Promise<void> {
const phraseLower = canonicalizeStoredPhrase(phrase);
await upsertOne(BannedPhrases.insert({phrase: phraseLower}));
@@ -299,14 +276,6 @@ export class AdminRepository implements IAdminRepository {
return rows.map((row) => row.phrase);
}
async isUrlBanned(url: string): Promise<boolean> {
const canonical = url.toLowerCase();
const result = await fetchOne<{
url_canonical: string;
}>(IS_URL_BANNED_QUERY.bind({url_canonical: canonical}));
return !!result;
}
async banUrl(row: BannedUrlRow): Promise<void> {
await upsertOne(BannedUrls.insert({...row, url_canonical: row.url_canonical.toLowerCase()}));
}
@@ -319,14 +288,6 @@ export class AdminRepository implements IAdminRepository {
return fetchMany<BannedUrlRow>(LOAD_ALL_BANNED_URLS_QUERY.bind({}));
}
async isUrlDomainBanned(domain: string): Promise<boolean> {
const d = domain.toLowerCase();
const result = await fetchOne<{
domain: string;
}>(IS_URL_DOMAIN_BANNED_QUERY.bind({domain: d}));
return !!result;
}
async banUrlDomain(row: BannedUrlDomainRow): Promise<void> {
await upsertOne(BannedUrlDomains.insert({...row, domain: row.domain.toLowerCase()}));
}
@@ -339,14 +300,6 @@ export class AdminRepository implements IAdminRepository {
return fetchMany<BannedUrlDomainRow>(LOAD_ALL_BANNED_URL_DOMAINS_QUERY.bind({}));
}
async isFileShaBanned(sha256Hex: string): Promise<boolean> {
const h = sha256Hex.toLowerCase();
const result = await fetchOne<{
sha256_hex: string;
}>(IS_FILE_SHA_BANNED_QUERY.bind({sha256_hex: h}));
return !!result;
}
async banFileSha(row: BannedFileShaRow): Promise<void> {
await upsertOne(BannedFileShas.insert({...row, sha256_hex: row.sha256_hex.toLowerCase()}));
}
@@ -368,14 +321,6 @@ export class AdminRepository implements IAdminRepository {
return fetchMany<BannedFileShaRow>(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({}));
}
async isAvatarHashBanned(hashShort: string): Promise<boolean> {
const h = hashShort.toLowerCase();
const result = await fetchOne<{
hash_short: string;
}>(IS_AVATAR_HASH_BANNED_QUERY.bind({hash_short: h}));
return !!result;
}
async banAvatarHash(row: BannedAvatarHashRow): Promise<void> {
await upsertOne(BannedAvatarHashes.insert({...row, hash_short: row.hash_short.toLowerCase()}));
}
@@ -59,8 +59,6 @@ export abstract class IAdminRepository {
abstract loadAllBannedEmails(): Promise<Array<string>>;
abstract isPhraseBanned(phrase: string): Promise<boolean>;
abstract banPhrase(phrase: string): Promise<void>;
abstract unbanPhrase(phrase: string): Promise<void>;
@@ -69,24 +67,18 @@ export abstract class IAdminRepository {
abstract loadAllBannedIps(): Promise<Set<string>>;
abstract isUrlBanned(url: string): Promise<boolean>;
abstract banUrl(row: BannedUrlRow): Promise<void>;
abstract unbanUrl(url: string): Promise<void>;
abstract loadAllBannedUrls(): Promise<Array<BannedUrlRow>>;
abstract isUrlDomainBanned(domain: string): Promise<boolean>;
abstract banUrlDomain(row: BannedUrlDomainRow): Promise<void>;
abstract unbanUrlDomain(domain: string): Promise<void>;
abstract loadAllBannedUrlDomains(): Promise<Array<BannedUrlDomainRow>>;
abstract isFileShaBanned(sha256Hex: string): Promise<boolean>;
abstract banFileSha(row: BannedFileShaRow): Promise<void>;
abstract unbanFileSha(sha256Hex: string): Promise<void>;
@@ -95,8 +87,6 @@ export abstract class IAdminRepository {
abstract loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>>;
abstract isAvatarHashBanned(hashShort: string): Promise<boolean>;
abstract banAvatarHash(row: BannedAvatarHashRow): Promise<void>;
abstract unbanAvatarHash(hashShort: string): Promise<void>;
@@ -12,30 +12,6 @@ describe('Admin API Key ACL Validation', () => {
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('user cannot grant ACLs they do not have - grant only ACLs user has', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: 'Test Key',
acls: ['audit_log:view'],
})
.expect(HTTP_STATUS.OK)
.execute();
});
test('user cannot grant ACLs they do not have - grant ACL user does not have', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: 'Test Key',
acls: ['audit_log:view', 'user:lookup'],
})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('user with wildcard can grant any ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['*']);
@@ -139,16 +115,4 @@ describe('Admin API Key ACL Validation', () => {
const keys = await listAdminApiKeys(harness, admin.token);
expect(keys[0]!.acls).toEqual(['audit_log:view']);
});
test('empty ACL list is valid', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: 'Test Key',
acls: [],
})
.expect(HTTP_STATUS.OK)
.execute();
});
});
@@ -1,72 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createAdminApiKeyWithDefaultACLs, revokeAdminApiKey} from '@app/api/admin/tests/AdminTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
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 {beforeEach, describe, expect, test} from 'vitest';
interface AdminApiKey {
keyId: string;
key: string;
name: string;
acls: Array<string>;
token: string;
}
async function createAdminApiKey(
harness: ApiTestHarness,
account: TestAccount,
name: string,
acls: Array<string>,
expiresInDays: number | null,
): Promise<AdminApiKey> {
const data = await createBuilder<{
key_id: string;
key: string;
name: string;
acls: Array<string>;
}>(harness, `${account.token}`)
.post('/admin/api-keys')
.body({
name,
acls,
...(expiresInDays !== null ? {expires_in_days: expiresInDays} : {}),
})
.expect(HTTP_STATUS.OK)
.execute();
return {
keyId: data.key_id,
key: data.key,
name: data.name,
acls: data.acls,
token: `Admin ${data.key}`,
};
}
async function createAdminApiKeyWithDefaultACLs(
harness: ApiTestHarness,
account: TestAccount,
name: string,
): Promise<AdminApiKey> {
return await createAdminApiKey(harness, account, name, ['audit_log:view', 'user:lookup', 'guild:lookup'], null);
}
async function listAdminApiKeys(harness: ApiTestHarness, token: string): Promise<Array<Record<string, unknown>>> {
return await createBuilder<Array<Record<string, unknown>>>(harness, `${token}`)
.get('/admin/api-keys')
.expect(HTTP_STATUS.OK)
.execute();
}
async function revokeAdminApiKey(harness: ApiTestHarness, token: string, keyId: string): Promise<void> {
await createBuilder(harness, `${token}`)
.delete(`/admin/api-keys/${keyId}`)
.body(null)
.expect(HTTP_STATUS.OK)
.execute();
}
import {beforeEach, describe, test} from 'vitest';
describe('Admin API Key Authentication', () => {
let harness: ApiTestHarness;
@@ -181,42 +120,4 @@ describe('Admin API Key Authentication', () => {
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Bot Endpoint Test Key');
await createBuilder(harness, `Bot ${apiKey.key}`).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
test('updates last_used_at timestamp', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [
'admin:authenticate',
'admin_api_key:manage',
'audit_log:view',
'user:lookup',
'guild:lookup',
]);
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Last Used Test Key');
const keys = await listAdminApiKeys(harness, admin.token);
expect(keys).toHaveLength(1);
expect(keys[0]!.last_used_at).toBeNull();
await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).execute();
const keysAfter = await listAdminApiKeys(harness, admin.token);
expect(keysAfter).toHaveLength(1);
expect(keysAfter[0]!.last_used_at).not.toBeNull();
});
test('multiple keys for same user all work', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
const key1 = await createAdminApiKey(harness, admin, 'Key 1', ['admin:authenticate', 'user:lookup'], null);
const key2 = await createAdminApiKey(harness, admin, 'Key 2', ['admin:authenticate', 'user:lookup'], null);
const key3 = await createAdminApiKey(harness, admin, 'Key 3', ['admin:authenticate', 'user:lookup'], null);
for (const key of [key1, key2, key3]) {
await createBuilder(harness, key.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute();
}
const keys = await listAdminApiKeys(harness, admin.token);
expect(keys).toHaveLength(3);
});
test('different users can use same key if creator has permissions', async () => {
const admin1 = await createTestAccount(harness);
const admin2 = await createTestAccount(harness);
await setUserACLs(harness, admin1, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
await setUserACLs(harness, admin2, ['admin:authenticate', 'user:lookup']);
const key1 = await createAdminApiKey(harness, admin1, 'Admin 1 Key', ['admin:authenticate', 'user:lookup'], null);
await createBuilder(harness, key1.token).get(`/admin/users/${admin2.userId}`).expect(HTTP_STATUS.OK).execute();
});
});
@@ -4,7 +4,6 @@ import {
createAdminApiKey,
createAdminApiKeyWithDefaultACLs,
listAdminApiKeys,
revokeAdminApiKey,
} from '@app/api/admin/tests/AdminTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
@@ -69,73 +68,6 @@ describe('Admin API Key Lifecycle', () => {
expect(data.expires_at).not.toBeNull();
expect(data.expires_at).toBeTruthy();
});
test('create API key with multiple ACLs', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [
'admin:authenticate',
'admin_api_key:manage',
'audit_log:view',
'user:lookup',
'guild:lookup',
]);
const data = await createBuilder<ApiKeyResponse>(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: 'Multi-ACL API Key',
acls: ['audit_log:view', 'user:lookup', 'guild:lookup'],
})
.expect(HTTP_STATUS.OK)
.execute();
expect(data.acls).toHaveLength(3);
});
test('name validation - empty name rejected', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: '',
acls: ['audit_log:view'],
})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
});
test('name validation - spaces only rejected', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: ' ',
acls: ['audit_log:view'],
})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
});
test('name validation - valid name accepted', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: 'My API Key',
acls: ['audit_log:view'],
})
.expect(HTTP_STATUS.OK)
.executeWithResponse();
});
test('name validation - special characters accepted', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: 'Key-123_Test!@#',
acls: ['audit_log:view'],
})
.expect(HTTP_STATUS.OK)
.executeWithResponse();
});
test('expiration validation - zero days rejected', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
@@ -149,78 +81,6 @@ describe('Admin API Key Lifecycle', () => {
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
});
test('expiration validation - negative days rejected', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: 'Test Key',
expires_in_days: -1,
acls: ['audit_log:view'],
})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
});
test('expiration validation - too many days rejected', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: 'Test Key',
expires_in_days: 366,
acls: ['audit_log:view'],
})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
});
test('expiration validation - valid minimum accepted', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: 'Test Key',
expires_in_days: 1,
acls: ['audit_log:view'],
})
.expect(HTTP_STATUS.OK)
.executeWithResponse();
});
test('expiration validation - valid maximum accepted', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
await createBuilder(harness, `${admin.token}`)
.post('/admin/api-keys')
.body({
name: 'Test Key',
expires_in_days: 365,
acls: ['audit_log:view'],
})
.expect(HTTP_STATUS.OK)
.executeWithResponse();
});
test('list empty API keys', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
const keys = await listAdminApiKeys(harness, admin.token);
expect(keys).toHaveLength(0);
});
test('list multiple API keys', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [
'admin:authenticate',
'admin_api_key:manage',
'audit_log:view',
'user:lookup',
'guild:lookup',
]);
await createAdminApiKeyWithDefaultACLs(harness, admin, 'First Key');
await createAdminApiKey(harness, admin, 'Second Key', ['user:lookup'], null);
const keys = await listAdminApiKeys(harness, admin.token);
expect(keys).toHaveLength(2);
});
test('list does not include secret key', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [
@@ -293,45 +153,6 @@ describe('Admin API Key Lifecycle', () => {
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
});
test('revoke API key', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [
'admin:authenticate',
'admin_api_key:manage',
'audit_log:view',
'user:lookup',
'guild:lookup',
]);
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Key to Revoke');
let keys = await listAdminApiKeys(harness, admin.token);
expect(keys).toHaveLength(1);
await revokeAdminApiKey(harness, admin.token, apiKey.keyId);
keys = await listAdminApiKeys(harness, admin.token);
expect(keys).toHaveLength(0);
});
test('revoke non-existent key', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
await createBuilder(harness, `${admin.token}`)
.delete('/admin/api-keys/999999999999999999')
.body(null)
.expect(HTTP_STATUS.NOT_FOUND)
.executeWithResponse();
});
test('revoked key cannot be used', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
const apiKey = await createAdminApiKey(harness, admin, 'Key to Test', ['user:lookup'], null);
await createBuilder(harness, apiKey.token)
.get(`/admin/users/${admin.userId}`)
.expect(HTTP_STATUS.OK)
.executeWithResponse();
await revokeAdminApiKey(harness, admin.token, apiKey.keyId);
await createBuilder(harness, apiKey.token)
.get(`/admin/users/${admin.userId}`)
.expect(HTTP_STATUS.UNAUTHORIZED)
.executeWithResponse();
});
test('only keys created by user are listed', async () => {
const admin1 = await createTestAccount(harness);
const admin2 = await createTestAccount(harness);
@@ -35,24 +35,6 @@ describe('Admin API Key Revocation', () => {
.execute();
expect(await hasAdminAPIKeyId(harness, admin.token, apiKey.keyId)).toBe(false);
});
test('revocation by ID', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [
'admin:authenticate',
'admin_api_key:manage',
'audit_log:view',
'user:lookup',
'guild:lookup',
]);
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'ID Test');
await createBuilder(harness, `${admin.token}`)
.delete(`/admin/api-keys/${apiKey.keyId}`)
.body(null)
.expect(HTTP_STATUS.OK)
.execute();
const keys = await listAdminApiKeys(harness, admin.token);
expect(keys).toHaveLength(0);
});
test('revocation of non-existent key returns 404', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [
@@ -7,11 +7,7 @@ import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {ADMIN_OAUTH2_APPLICATION_ID} from '@fluxer/constants/src/Core';
import type {
ApplicationUpdateResponse,
ListApplicationsResponse,
LookupApplicationResponse,
} from '@fluxer/schema/src/domains/admin/AdminApplicationSchemas';
import type {ApplicationUpdateResponse} from '@fluxer/schema/src/domains/admin/AdminApplicationSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface AuditLogsResponse {
@@ -33,42 +29,6 @@ describe('Admin applications', () => {
await harness.shutdown();
});
test('gets an application by id', async () => {
const owner = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName('Lookup App'),
redirect_uris: ['https://example.test/callback'],
});
await setUserACLs(harness, owner, [AdminACLs.AUTHENTICATE, AdminACLs.APPLICATION_LOOKUP]);
const response = await createBuilder<LookupApplicationResponse>(harness, `${owner.token}`)
.get(`/admin/applications/${app.application.id}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(response.application).toMatchObject({
id: app.application.id,
owner_user_id: owner.userId,
});
});
test('lists applications owned by a user', async () => {
const owner = await createTestAccount(harness);
const app = await createOAuth2Application(harness, owner.token, {
name: createUniqueApplicationName('Owned App'),
redirect_uris: ['https://example.test/callback'],
});
await setUserACLs(harness, owner, [AdminACLs.AUTHENTICATE, AdminACLs.APPLICATION_LIST_BY_OWNER]);
const response = await createBuilder<ListApplicationsResponse>(harness, `${owner.token}`)
.get(`/admin/applications?owner_id=${owner.userId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(response.applications).toHaveLength(1);
expect(response.applications[0]).toMatchObject({id: app.application.id});
});
test('rejects a list by owner from a lookup-only admin', async () => {
const owner = await createTestAccount(harness);
await createOAuth2Application(harness, owner.token, {
@@ -83,16 +43,6 @@ describe('Admin applications', () => {
.executeWithResponse();
});
test('rejects a list without owner_id or guild_id', async () => {
const owner = await createTestAccount(harness);
await setUserACLs(harness, owner, [AdminACLs.AUTHENTICATE, AdminACLs.APPLICATION_LOOKUP]);
await createBuilder(harness, `${owner.token}`)
.get('/admin/applications')
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
});
test('transfers application ownership', async () => {
const owner = await createTestAccount(harness);
const newOwner = await createTestAccount(harness);

Some files were not shown because too many files have changed in this diff Show More