|
|
|
|
//! HTTP integration tests for synctv-api
|
|
|
|
|
//!
|
|
|
|
|
//! Tests the HTTP layer: error responses, security headers, and health endpoints.
|
|
|
|
|
//!
|
|
|
|
|
//! These tests use `tower::ServiceExt::oneshot()` to test the axum router
|
|
|
|
|
//! without starting a TCP server, and do not require database or Redis.
|
|
|
|
|
|
|
|
|
|
#![allow(clippy::unwrap_used)]
|
|
|
|
|
use axum::{
|
|
|
|
|
body::Body,
|
|
|
|
|
http::{Request, StatusCode},
|
|
|
|
|
middleware as axum_middleware,
|
|
|
|
|
routing::get,
|
|
|
|
|
Router,
|
|
|
|
|
};
|
|
|
|
|
use http_body_util::BodyExt;
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
use tower::ServiceExt;
|
|
|
|
|
|
|
|
|
|
async fn body_json(response: axum::response::Response) -> Value {
|
|
|
|
|
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
|
|
|
|
serde_json::from_slice(&bytes).unwrap_or(Value::Null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn grpc_code_for_http_status(status: StatusCode) -> i32 {
|
|
|
|
|
match status {
|
|
|
|
|
StatusCode::BAD_REQUEST => tonic::Code::InvalidArgument as i32,
|
|
|
|
|
StatusCode::UNAUTHORIZED => tonic::Code::Unauthenticated as i32,
|
|
|
|
|
StatusCode::FORBIDDEN => tonic::Code::PermissionDenied as i32,
|
|
|
|
|
StatusCode::NOT_FOUND => tonic::Code::NotFound as i32,
|
|
|
|
|
StatusCode::CONFLICT => tonic::Code::Aborted as i32,
|
|
|
|
|
StatusCode::TOO_MANY_REQUESTS => tonic::Code::ResourceExhausted as i32,
|
|
|
|
|
StatusCode::SERVICE_UNAVAILABLE => tonic::Code::Unavailable as i32,
|
|
|
|
|
StatusCode::GATEWAY_TIMEOUT | StatusCode::REQUEST_TIMEOUT => {
|
|
|
|
|
tonic::Code::DeadlineExceeded as i32
|
|
|
|
|
}
|
|
|
|
|
_ => tonic::Code::Internal as i32,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mod error_responses {
|
|
|
|
|
use super::*;
|
|
|
|
|
use synctv_api::AppError;
|
|
|
|
|
|
|
|
|
|
fn error_router(error: &AppError) -> Router {
|
|
|
|
|
let status = error.status();
|
|
|
|
|
let message = error.message().to_string();
|
|
|
|
|
Router::new().route(
|
|
|
|
|
"/test",
|
|
|
|
|
get(move || async move { Err::<String, AppError>(AppError::new(status, message)) }),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_error_response_statuses() {
|
|
|
|
|
let cases = [
|
|
|
|
|
(
|
|
|
|
|
AppError::bad_request("invalid input"),
|
|
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
|
|
Some("invalid input"),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::unauthorized("not authenticated"),
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Some("not authenticated"),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::forbidden("access denied"),
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
Some("access denied"),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::not_found("room not found"),
|
|
|
|
|
StatusCode::NOT_FOUND,
|
|
|
|
|
Some("room not found"),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::conflict("already exists"),
|
|
|
|
|
StatusCode::CONFLICT,
|
|
|
|
|
None,
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::rate_limited(30),
|
|
|
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
|
|
|
Some("30"),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::internal_server_error("something broke"),
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
None,
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::service_unavailable(),
|
|
|
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
|
|
|
None,
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (error, expected_status, expected_message_part) in cases {
|
|
|
|
|
let app = error_router(&error);
|
|
|
|
|
let req = Request::get("/test").body(Body::empty()).unwrap();
|
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(resp.status(), expected_status);
|
|
|
|
|
let json = body_json(resp).await;
|
|
|
|
|
assert_eq!(json["code"], grpc_code_for_http_status(expected_status));
|
|
|
|
|
if let Some(expected_message_part) = expected_message_part {
|
|
|
|
|
assert!(
|
|
|
|
|
json["message"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.expect("error response should contain a string message")
|
|
|
|
|
.contains(expected_message_part),
|
|
|
|
|
"expected error message to contain {expected_message_part:?}, got {:?}",
|
|
|
|
|
json["message"]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_error_response_json_structure() {
|
|
|
|
|
let app = error_router(&AppError::bad_request("test"));
|
|
|
|
|
let req = Request::get("/test").body(Body::empty()).unwrap();
|
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
|
|
|
|
let json = body_json(resp).await;
|
|
|
|
|
let obj = json.as_object().unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
obj.contains_key("code"),
|
|
|
|
|
"Response must contain 'code' field"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
obj.contains_key("message"),
|
|
|
|
|
"Response must contain 'message' field"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
obj.contains_key("details"),
|
|
|
|
|
"Response must contain 'details' field"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_app_error_helpers_return_public_messages() {
|
|
|
|
|
let cases = [
|
|
|
|
|
(
|
|
|
|
|
AppError::permission_denied(),
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
Vec::new(),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::bad_request("Invalid email format"),
|
|
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
|
|
vec!["Invalid email format"],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::unauthorized("Token has expired"),
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
vec!["Token has expired"],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::forbidden("Admin access required"),
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
vec!["Admin access required"],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::not_found("Room 'abc123' does not exist"),
|
|
|
|
|
StatusCode::NOT_FOUND,
|
|
|
|
|
vec!["Room 'abc123' does not exist"],
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (error, expected_status, expected_parts) in cases {
|
|
|
|
|
let app = error_router(&error);
|
|
|
|
|
let req = Request::get("/test").body(Body::empty()).unwrap();
|
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(resp.status(), expected_status);
|
|
|
|
|
let json = body_json(resp).await;
|
|
|
|
|
let message = json["message"].as_str().unwrap();
|
|
|
|
|
for expected_part in expected_parts {
|
|
|
|
|
assert!(
|
|
|
|
|
message.contains(expected_part),
|
|
|
|
|
"expected {message:?} to contain {expected_part:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_internal_error_returns_generic_message() {
|
|
|
|
|
let sensitive_msg =
|
|
|
|
|
"Database connection failed: postgres://admin:secret@db.internal:5432/production";
|
|
|
|
|
let app = error_router(&AppError::internal_server_error(sensitive_msg));
|
|
|
|
|
let req = Request::get("/test").body(Body::empty()).unwrap();
|
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
|
|
|
let json = body_json(resp).await;
|
|
|
|
|
let error_msg = json["message"].as_str().unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!error_msg.contains("postgres://"),
|
|
|
|
|
"Internal error response must not leak database connection strings"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!error_msg.contains("secret"),
|
|
|
|
|
"Internal error response must not leak passwords"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!error_msg.contains("db.internal"),
|
|
|
|
|
"Internal error response must not leak internal hostnames"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
error_msg, "Internal error",
|
|
|
|
|
"Internal error should return generic message"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_sensitive_info_never_leaked_in_responses() {
|
|
|
|
|
let sensitive_patterns = [
|
|
|
|
|
"password",
|
|
|
|
|
"secret",
|
|
|
|
|
"api_key",
|
|
|
|
|
"private_key",
|
|
|
|
|
"postgres://",
|
|
|
|
|
"mysql://",
|
|
|
|
|
"redis://",
|
|
|
|
|
"mongodb://",
|
|
|
|
|
"/etc/",
|
|
|
|
|
"stack trace",
|
|
|
|
|
"Error:",
|
|
|
|
|
"panic!",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let sensitive_msg = "Error: panic! at /etc/config.toml - postgres://admin:password@localhost redis://localhost private_key=abc123";
|
|
|
|
|
let app = error_router(&AppError::internal_server_error(sensitive_msg));
|
|
|
|
|
let req = Request::get("/test").body(Body::empty()).unwrap();
|
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
|
|
|
let json = body_json(resp).await;
|
|
|
|
|
let error_msg = json["message"].as_str().unwrap().to_lowercase();
|
|
|
|
|
|
|
|
|
|
for pattern in &sensitive_patterns {
|
|
|
|
|
assert!(
|
|
|
|
|
!error_msg.contains(&pattern.to_lowercase()),
|
|
|
|
|
"Response must not contain sensitive pattern: {pattern}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_server_error_semantics_are_preserved_with_safe_messages() {
|
|
|
|
|
let cases = [
|
|
|
|
|
(
|
|
|
|
|
AppError::service_unavailable(),
|
|
|
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
|
|
|
"Service temporarily unavailable. Please try again later.",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::new(
|
|
|
|
|
StatusCode::BAD_GATEWAY,
|
|
|
|
|
"Upstream nginx error: connection reset by peer",
|
|
|
|
|
),
|
|
|
|
|
StatusCode::BAD_GATEWAY,
|
|
|
|
|
"Upstream service error",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
AppError::new(StatusCode::GATEWAY_TIMEOUT, "Upstream timeout after 30s"),
|
|
|
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
|
|
|
"Upstream service timed out",
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (error, expected_status, expected_message) in cases {
|
|
|
|
|
let app = error_router(&error);
|
|
|
|
|
let req = Request::get("/test").body(Body::empty()).unwrap();
|
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(resp.status(), expected_status);
|
|
|
|
|
let json = body_json(resp).await;
|
|
|
|
|
assert_eq!(json["message"], expected_message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mod error_classification {
|
|
|
|
|
use axum::http::StatusCode;
|
refactor: remove low-value thin wrappers (#426)
## Summary
- remove unused compatibility APIs and low-value forwarding wrappers
across the workspace
- inline service, repository, provider, cluster, proxy, and protocol
paths while preserving existing behavior
- remove stale dependencies, metrics, tests, and SQLx metadata generated
for deleted queries
## Validation
- `cargo check --workspace --all-targets`
- `cargo fmt --all -- --check`
- `git diff --check`
- relevant test suites: 1,408 passed, 184 ignored
- broader test run: 2,286 passed
1 month ago
|
|
|
use synctv_api::{map_api_error, ApiError};
|
|
|
|
|
use synctv_core::Error as CoreError;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_api_errors_map_to_http_status_and_public_messages() {
|
|
|
|
|
let cases = [
|
|
|
|
|
(
|
|
|
|
|
ApiError::NotFound("room abc123".into()),
|
|
|
|
|
StatusCode::NOT_FOUND,
|
|
|
|
|
Some("room abc123"),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
ApiError::Authentication("token expired".into()),
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Some("token expired"),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
ApiError::Authorization("forbidden".into()),
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
Some("forbidden"),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
ApiError::AlreadyExists("username taken".into()),
|
|
|
|
|
StatusCode::CONFLICT,
|
|
|
|
|
Some("username taken"),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
ApiError::InvalidInput("bad email format".into()),
|
|
|
|
|
StatusCode::BAD_REQUEST,
|
|
|
|
|
Some("bad email format"),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
ApiError::Internal("Something went wrong".into()),
|
|
|
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
Some("Internal error"),
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (api_error, expected_status, expected_message) in cases {
|
|
|
|
|
let err = map_api_error(api_error);
|
|
|
|
|
assert_eq!(err.status(), expected_status);
|
|
|
|
|
if let Some(expected_message) = expected_message {
|
|
|
|
|
assert!(
|
|
|
|
|
err.message().contains(expected_message),
|
|
|
|
|
"expected mapped error message to contain {expected_message:?}, got {:?}",
|
|
|
|
|
err.message()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_core_errors_map_to_http_statuses() {
|
|
|
|
|
let cases = [
|
|
|
|
|
(
|
|
|
|
|
CoreError::NotFound("room 123".into()),
|
|
|
|
|
StatusCode::NOT_FOUND,
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
CoreError::Authentication("expired".into()),
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
CoreError::Authorization("denied".into()),
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (core_err, expected_status) in cases {
|
|
|
|
|
let app_err = map_api_error(ApiError::from(core_err));
|
|
|
|
|
assert_eq!(app_err.status(), expected_status);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mod security_headers {
|
|
|
|
|
use super::*;
|
|
|
|
|
use synctv_api::security_headers_middleware;
|
|
|
|
|
|
|
|
|
|
fn security_app() -> Router {
|
|
|
|
|
Router::new()
|
|
|
|
|
.route("/test", get(|| async { "ok" }))
|
|
|
|
|
.layer(axum_middleware::from_fn(security_headers_middleware))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_security_header_values() {
|
|
|
|
|
let app = security_app();
|
|
|
|
|
let req = Request::get("/test").body(Body::empty()).unwrap();
|
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(resp.headers().get("X-Frame-Options").unwrap(), "DENY");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resp.headers().get("X-Content-Type-Options").unwrap(),
|
|
|
|
|
"nosniff"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resp.headers().get("Referrer-Policy").unwrap(),
|
|
|
|
|
"strict-origin-when-cross-origin"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let csp = resp
|
|
|
|
|
.headers()
|
|
|
|
|
.get("Content-Security-Policy")
|
|
|
|
|
.unwrap()
|
|
|
|
|
.to_str()
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(csp.contains("default-src 'self'"));
|
|
|
|
|
assert!(csp.contains("frame-ancestors 'none'"));
|
feat(web): add client-aware playback and reproducible UI (#433)
## Summary
- add a versioned playback client profile for browser/runtime protocol,
container, codec, header, proxy, insecure-media, and P2P-loader
capabilities
- generate compatible direct and proxy resources inside each provider
and return a structured incompatibility error when route policy leaves
no viable result
- force provider proxy delivery for browser-forbidden headers, including
affected Bilibili variants, while preserving explicit direct-only
failures
- keep legacy clients compatible and isolate capability-aware playback
cache entries
- serve `/oauth2/callback` through the same Flutter SPA entry point and
keep public discovery anonymous
## Reproducible Web UI
- move Flutter acquisition/build, source configuration, asset manifests,
Brotli/gzip compression, and compile-time embedding into the independent
`synctv-web-ui` crate
- support prebuilt distributions, local projects, and Git sources pinned
to an immutable full commit, with an ignored local override
- fingerprint source, Flutter version, build arguments, dart-defines,
builder generation, output, and compression settings
- validate cached Git repository/revision/commit identity, support
offline cache reuse, and rebuild only when relevant inputs change
- pin and checksum the Flutter SDK in the Docker Web asset stage;
backend-only builds require no Flutter, Git fetch, or network
- serve embedded assets with ETags, compression negotiation, CSP, cache
policy, Origin/CORS handling, and SPA fallback outside API/media routes
## Verification
- `cargo fmt --all -- --check`
- workspace `cargo check`, test/doc-test, and all-targets Clippy with
warnings denied
- provider tests: 200 passed, 1 ignored
- OAuth core/API tests: 72 + 16 passed
- `synctv-web-ui`: 10 passed
- `synctv-api-http --features web-ui`: 12 passed
- default Git source cold build from the pinned App commit, followed by
offline hot-cache reuse in about 1.1 seconds
- `cargo check -p synctv --features web-ui` and `docker buildx build
--check .`
- real Chrome playback, P2P, OAuth/Casdoor, multi-user sync, chat,
media, playlist, upload, settings, and playback-history flows
Companion frontend PR: https://github.com/synctv-org/synctv-app/pull/52
Release pin validation:
https://github.com/synctv-org/synctv-release/pull/9
1 month ago
|
|
|
assert!(csp.contains("media-src 'self' blob: https: http:"));
|
|
|
|
|
assert!(csp.contains("frame-src 'self'"));
|
|
|
|
|
assert!(csp.contains("connect-src 'self' blob: https: http: wss: ws:"));
|
|
|
|
|
assert!(csp.contains("worker-src 'self' blob:"));
|
|
|
|
|
|
|
|
|
|
let pp = resp
|
|
|
|
|
.headers()
|
|
|
|
|
.get("Permissions-Policy")
|
|
|
|
|
.unwrap()
|
|
|
|
|
.to_str()
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(pp.contains("camera=()"));
|
feat(web): add client-aware playback and reproducible UI (#433)
## Summary
- add a versioned playback client profile for browser/runtime protocol,
container, codec, header, proxy, insecure-media, and P2P-loader
capabilities
- generate compatible direct and proxy resources inside each provider
and return a structured incompatibility error when route policy leaves
no viable result
- force provider proxy delivery for browser-forbidden headers, including
affected Bilibili variants, while preserving explicit direct-only
failures
- keep legacy clients compatible and isolate capability-aware playback
cache entries
- serve `/oauth2/callback` through the same Flutter SPA entry point and
keep public discovery anonymous
## Reproducible Web UI
- move Flutter acquisition/build, source configuration, asset manifests,
Brotli/gzip compression, and compile-time embedding into the independent
`synctv-web-ui` crate
- support prebuilt distributions, local projects, and Git sources pinned
to an immutable full commit, with an ignored local override
- fingerprint source, Flutter version, build arguments, dart-defines,
builder generation, output, and compression settings
- validate cached Git repository/revision/commit identity, support
offline cache reuse, and rebuild only when relevant inputs change
- pin and checksum the Flutter SDK in the Docker Web asset stage;
backend-only builds require no Flutter, Git fetch, or network
- serve embedded assets with ETags, compression negotiation, CSP, cache
policy, Origin/CORS handling, and SPA fallback outside API/media routes
## Verification
- `cargo fmt --all -- --check`
- workspace `cargo check`, test/doc-test, and all-targets Clippy with
warnings denied
- provider tests: 200 passed, 1 ignored
- OAuth core/API tests: 72 + 16 passed
- `synctv-web-ui`: 10 passed
- `synctv-api-http --features web-ui`: 12 passed
- default Git source cold build from the pinned App commit, followed by
offline hot-cache reuse in about 1.1 seconds
- `cargo check -p synctv --features web-ui` and `docker buildx build
--check .`
- real Chrome playback, P2P, OAuth/Casdoor, multi-user sync, chat,
media, playlist, upload, settings, and playback-history flows
Companion frontend PR: https://github.com/synctv-org/synctv-app/pull/52
Release pin validation:
https://github.com/synctv-org/synctv-release/pull/9
1 month ago
|
|
|
assert!(pp.contains("microphone=(self)"));
|
|
|
|
|
assert!(pp.contains("picture-in-picture=(self)"));
|
|
|
|
|
assert!(pp.contains("geolocation=()"));
|
|
|
|
|
|
|
|
|
|
let cc = resp
|
|
|
|
|
.headers()
|
|
|
|
|
.get("Cache-Control")
|
|
|
|
|
.unwrap()
|
|
|
|
|
.to_str()
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(cc.contains("no-store"));
|
|
|
|
|
assert!(cc.contains("no-cache"));
|
|
|
|
|
assert!(cc.contains("must-revalidate"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_does_not_overwrite_existing_x_frame_options() {
|
|
|
|
|
let app = Router::new()
|
|
|
|
|
.route(
|
|
|
|
|
"/test",
|
|
|
|
|
get(|| async {
|
|
|
|
|
(
|
|
|
|
|
[(
|
|
|
|
|
axum::http::header::HeaderName::from_static("x-frame-options"),
|
|
|
|
|
"SAMEORIGIN",
|
|
|
|
|
)],
|
|
|
|
|
"ok",
|
|
|
|
|
)
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.layer(axum_middleware::from_fn(security_headers_middleware));
|
|
|
|
|
|
|
|
|
|
let req = Request::get("/test").body(Body::empty()).unwrap();
|
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(resp.headers().get("X-Frame-Options").unwrap(), "SAMEORIGIN");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_all_security_headers_present_on_404() {
|
|
|
|
|
let app = Router::new()
|
|
|
|
|
.route("/other", get(|| async { "ok" }))
|
|
|
|
|
.layer(axum_middleware::from_fn(security_headers_middleware));
|
|
|
|
|
|
|
|
|
|
let req = Request::get("/nonexistent").body(Body::empty()).unwrap();
|
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(resp.headers().contains_key("X-Frame-Options"));
|
|
|
|
|
assert!(resp.headers().contains_key("X-Content-Type-Options"));
|
|
|
|
|
assert!(resp.headers().contains_key("Referrer-Policy"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mod hsts_headers {
|
|
|
|
|
use synctv_api::hsts_header;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_hsts_header_variants() {
|
|
|
|
|
let cases = [
|
|
|
|
|
(31_536_000, false, false, "max-age=31536000"),
|
|
|
|
|
(
|
|
|
|
|
31_536_000,
|
|
|
|
|
true,
|
|
|
|
|
false,
|
|
|
|
|
"max-age=31536000; includeSubDomains",
|
|
|
|
|
),
|
|
|
|
|
(31_536_000, false, true, "max-age=31536000; preload"),
|
|
|
|
|
(
|
|
|
|
|
63_072_000,
|
|
|
|
|
true,
|
|
|
|
|
true,
|
|
|
|
|
"max-age=63072000; includeSubDomains; preload",
|
|
|
|
|
),
|
|
|
|
|
(0, false, false, "max-age=0"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (max_age, include_subdomains, preload, expected) in cases {
|
|
|
|
|
assert_eq!(hsts_header(max_age, include_subdomains, preload), expected);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mod auth_flow {
|
|
|
|
|
use synctv_core::models::UserId;
|
|
|
|
|
use synctv_core::service::JwtService;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_cross_secret_rejection() {
|
|
|
|
|
let jwt_a =
|
|
|
|
|
JwtService::new("secret-aaaa-long-enough-for-entropy-check-1234567890").unwrap();
|
|
|
|
|
let jwt_b =
|
|
|
|
|
JwtService::new("secret-bbbb-long-enough-for-entropy-check-1234567890").unwrap();
|
|
|
|
|
let user_id = UserId::new();
|
|
|
|
|
|
|
|
|
|
let token = jwt_a.sign_access_token(&user_id, 0).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
jwt_b.verify_access_token(&token).is_err(),
|
|
|
|
|
"cross-secret token must be rejected"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|