FIX: TLS options
This commit is contained in:
18
README.md
18
README.md
@@ -26,11 +26,10 @@ cargo build --release --workspace
|
|||||||
--token mysecrettoken \
|
--token mysecrettoken \
|
||||||
--tls-cert-out /tmp/furumi-ca.pem
|
--tls-cert-out /tmp/furumi-ca.pem
|
||||||
|
|
||||||
# Client — loads the server's certificate for encrypted connection
|
# Client — automatically uses TLS, trusts server certificate
|
||||||
./target/release/furumi-mount-linux \
|
./target/release/furumi-mount-linux \
|
||||||
--server https://server-ip:50051 \
|
--server server-ip:50051 \
|
||||||
--token mysecrettoken \
|
--token mysecrettoken \
|
||||||
--tls-ca /tmp/furumi-ca.pem \
|
|
||||||
--mount /mnt/remote
|
--mount /mnt/remote
|
||||||
|
|
||||||
# Use it
|
# Use it
|
||||||
@@ -40,13 +39,9 @@ mpv /mnt/remote/video.mkv
|
|||||||
|
|
||||||
## Encryption
|
## Encryption
|
||||||
|
|
||||||
TLS is enabled by default. The server auto-generates a self-signed certificate on each start — no manual cert management required. The certificate is used **only for encryption**, not for server identity verification.
|
TLS is enabled by default. The server auto-generates a self-signed certificate on each start — no manual cert management required. The client automatically trusts the server's certificate for encryption.
|
||||||
|
|
||||||
To pass the certificate to the client:
|
To disable TLS (not recommended): `--no-tls` on both server and client.
|
||||||
1. Server: `--tls-cert-out /path/to/cert.pem` saves the generated cert
|
|
||||||
2. Client: `--tls-ca /path/to/cert.pem` loads it for the TLS handshake
|
|
||||||
|
|
||||||
To disable TLS (not recommended): `--no-tls` on the server, and use `http://` on the client.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -60,17 +55,16 @@ All options can be set via CLI flags or environment variables.
|
|||||||
| `--root` | `FURUMI_ROOT` | `.` | Directory to expose |
|
| `--root` | `FURUMI_ROOT` | `.` | Directory to expose |
|
||||||
| `--token` | `FURUMI_TOKEN` | *(empty, auth off)* | Bearer token |
|
| `--token` | `FURUMI_TOKEN` | *(empty, auth off)* | Bearer token |
|
||||||
| `--metrics-bind` | `FURUMI_METRICS_BIND` | `0.0.0.0:9090` | Prometheus endpoint |
|
| `--metrics-bind` | `FURUMI_METRICS_BIND` | `0.0.0.0:9090` | Prometheus endpoint |
|
||||||
| `--tls-cert-out` | `FURUMI_TLS_CERT_OUT` | — | Save auto-generated cert PEM |
|
|
||||||
| `--no-tls` | — | `false` | Disable TLS |
|
| `--no-tls` | — | `false` | Disable TLS |
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
|
|
||||||
| Flag | Env | Default | Description |
|
| Flag | Env | Default | Description |
|
||||||
|------|-----|---------|-------------|
|
|------|-----|---------|-------------|
|
||||||
| `--server` | `FURUMI_SERVER` | `https://0.0.0.0:50051` | Server address |
|
| `--server` | `FURUMI_SERVER` | `0.0.0.0:50051` | Server address |
|
||||||
| `--token` | `FURUMI_TOKEN` | *(empty)* | Bearer token |
|
| `--token` | `FURUMI_TOKEN` | *(empty)* | Bearer token |
|
||||||
| `--mount` | `FURUMI_MOUNT` | — | Mount point directory |
|
| `--mount` | `FURUMI_MOUNT` | — | Mount point directory |
|
||||||
| `--tls-ca` | `FURUMI_TLS_CA` | — | Server CA cert PEM file |
|
| `--no-tls` | — | `false` | Disable TLS |
|
||||||
|
|
||||||
## Prometheus Metrics
|
## Prometheus Metrics
|
||||||
|
|
||||||
|
|||||||
@@ -246,3 +246,6 @@ impl FurumiClient {
|
|||||||
Ok(stream)
|
Ok(stream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|||||||
66
furumi-client-core/src/client/tests.rs
Normal file
66
furumi-client-core/src/client/tests.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use crate::client::AuthInterceptor;
|
||||||
|
use tonic::{Request, service::Interceptor};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_interceptor_with_token() {
|
||||||
|
let mut interceptor = AuthInterceptor {
|
||||||
|
token: "my-secret-token".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = Request::new(());
|
||||||
|
let res = interceptor.call(req).expect("Failed to intercept");
|
||||||
|
|
||||||
|
let auth_header = res.metadata().get("authorization").expect("Missing auth header");
|
||||||
|
assert_eq!(auth_header.to_str().unwrap(), "Bearer my-secret-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_interceptor_empty_token() {
|
||||||
|
let mut interceptor = AuthInterceptor {
|
||||||
|
token: "".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = Request::new(());
|
||||||
|
let res = interceptor.call(req).expect("Failed to intercept");
|
||||||
|
|
||||||
|
assert!(res.metadata().get("authorization").is_none(), "Auth header should not be set for empty token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_interceptor_invalid_chars() {
|
||||||
|
let mut interceptor = AuthInterceptor {
|
||||||
|
// ASCII control characters are invalid in Metadata values
|
||||||
|
token: "token\nwith\nnewlines".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = Request::new(());
|
||||||
|
let res = interceptor.call(req);
|
||||||
|
|
||||||
|
assert!(res.is_err(), "Interceptor should fail on invalid token characters");
|
||||||
|
assert_eq!(res.unwrap_err().code(), tonic::Code::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
use furumi_common::proto::AttrResponse;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_client_caching_logic() {
|
||||||
|
let cache = moka::future::Cache::builder()
|
||||||
|
.max_capacity(100)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let path = "/test/file.txt";
|
||||||
|
let attr = AttrResponse {
|
||||||
|
size: 1024,
|
||||||
|
mode: 0o644,
|
||||||
|
mtime: 1234567890,
|
||||||
|
};
|
||||||
|
|
||||||
|
cache.insert(path.to_string(), attr.clone()).await;
|
||||||
|
|
||||||
|
let cached_attr = cache.get(path).await.expect("Item should be in cache");
|
||||||
|
assert_eq!(cached_attr.size, attr.size);
|
||||||
|
assert_eq!(cached_attr.mode, attr.mode);
|
||||||
|
assert_eq!(cached_attr.mtime, attr.mtime);
|
||||||
|
|
||||||
|
assert!(cache.get("/non/existent").await.is_none());
|
||||||
|
}
|
||||||
@@ -11,20 +11,18 @@ use tokio::runtime::Handle;
|
|||||||
|
|
||||||
const TTL: Duration = Duration::from_secs(1); // 1 second FUSE kernel TTL
|
const TTL: Duration = Duration::from_secs(1); // 1 second FUSE kernel TTL
|
||||||
|
|
||||||
pub struct FurumiFuse {
|
// ── InodeMapper ──────────────────────────────────────────────────
|
||||||
client: FurumiClient,
|
|
||||||
rt_handle: Handle,
|
/// Maps FUSE inodes (u64) to remote virtual filesystem paths (String) and vice versa.
|
||||||
// FUSE deals in inodes (u64). We need to map inode -> string path.
|
/// Inode 1 is permanently assigned to the root directory ("/").
|
||||||
// In a real VFS, this requires a proper tree or bidirectional map.
|
pub struct InodeMapper {
|
||||||
// For simplicity: inode 1 is always the root "/".
|
|
||||||
inode_to_path: Arc<Mutex<HashMap<u64, String>>>,
|
inode_to_path: Arc<Mutex<HashMap<u64, String>>>,
|
||||||
// Mapping path back to inode to keep them consistent
|
|
||||||
path_to_inode: Arc<Mutex<HashMap<String, u64>>>,
|
path_to_inode: Arc<Mutex<HashMap<String, u64>>>,
|
||||||
next_inode: Arc<Mutex<u64>>,
|
next_inode: Arc<Mutex<u64>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FurumiFuse {
|
impl InodeMapper {
|
||||||
pub fn new(client: FurumiClient, rt_handle: Handle) -> Self {
|
pub fn new() -> Self {
|
||||||
let mut inode_to_path = HashMap::new();
|
let mut inode_to_path = HashMap::new();
|
||||||
let mut path_to_inode = HashMap::new();
|
let mut path_to_inode = HashMap::new();
|
||||||
|
|
||||||
@@ -33,15 +31,13 @@ impl FurumiFuse {
|
|||||||
path_to_inode.insert("/".to_string(), 1);
|
path_to_inode.insert("/".to_string(), 1);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
client,
|
|
||||||
rt_handle,
|
|
||||||
inode_to_path: Arc::new(Mutex::new(inode_to_path)),
|
inode_to_path: Arc::new(Mutex::new(inode_to_path)),
|
||||||
path_to_inode: Arc::new(Mutex::new(path_to_inode)),
|
path_to_inode: Arc::new(Mutex::new(path_to_inode)),
|
||||||
next_inode: Arc::new(Mutex::new(2)), // Inodes start from 2
|
next_inode: Arc::new(Mutex::new(2)), // Inodes start from 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_inode(&self, path: &str) -> u64 {
|
pub fn get_inode(&self, path: &str) -> u64 {
|
||||||
let mut p2i = self.path_to_inode.lock().unwrap();
|
let mut p2i = self.path_to_inode.lock().unwrap();
|
||||||
if let Some(&inode) = p2i.get(path) {
|
if let Some(&inode) = p2i.get(path) {
|
||||||
inode
|
inode
|
||||||
@@ -55,9 +51,27 @@ impl FurumiFuse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_path(&self, inode: u64) -> Option<String> {
|
pub fn get_path(&self, inode: u64) -> Option<String> {
|
||||||
self.inode_to_path.lock().unwrap().get(&inode).cloned()
|
self.inode_to_path.lock().unwrap().get(&inode).cloned()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FurumiFuse ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct FurumiFuse {
|
||||||
|
client: FurumiClient,
|
||||||
|
rt_handle: Handle,
|
||||||
|
mapper: InodeMapper,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FurumiFuse {
|
||||||
|
pub fn new(client: FurumiClient, rt_handle: Handle) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
rt_handle,
|
||||||
|
mapper: InodeMapper::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn make_attr(inode: u64, attr: &furumi_common::proto::AttrResponse) -> FileAttr {
|
fn make_attr(inode: u64, attr: &furumi_common::proto::AttrResponse) -> FileAttr {
|
||||||
let kind = if attr.mode & libc::S_IFDIR != 0 {
|
let kind = if attr.mode & libc::S_IFDIR != 0 {
|
||||||
@@ -91,7 +105,7 @@ impl Filesystem for FurumiFuse {
|
|||||||
let name_str = name.to_string_lossy();
|
let name_str = name.to_string_lossy();
|
||||||
debug!("lookup: parent={} name={}", parent, name_str);
|
debug!("lookup: parent={} name={}", parent, name_str);
|
||||||
|
|
||||||
let parent_path = match self.get_path(parent) {
|
let parent_path = match self.mapper.get_path(parent) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
reply.error(libc::ENOENT);
|
reply.error(libc::ENOENT);
|
||||||
@@ -115,7 +129,7 @@ impl Filesystem for FurumiFuse {
|
|||||||
|
|
||||||
match attr_res {
|
match attr_res {
|
||||||
Ok(attr) => {
|
Ok(attr) => {
|
||||||
let inode = self.get_inode(&full_path);
|
let inode = self.mapper.get_inode(&full_path);
|
||||||
reply.entry(&TTL, &Self::make_attr(inode, &attr), 0);
|
reply.entry(&TTL, &Self::make_attr(inode, &attr), 0);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -127,7 +141,7 @@ impl Filesystem for FurumiFuse {
|
|||||||
fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
|
fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
|
||||||
debug!("getattr: ino={}", ino);
|
debug!("getattr: ino={}", ino);
|
||||||
|
|
||||||
let path = match self.get_path(ino) {
|
let path = match self.mapper.get_path(ino) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
reply.error(libc::ENOENT);
|
reply.error(libc::ENOENT);
|
||||||
@@ -158,7 +172,7 @@ impl Filesystem for FurumiFuse {
|
|||||||
) {
|
) {
|
||||||
debug!("readdir: ino={} offset={}", ino, offset);
|
debug!("readdir: ino={} offset={}", ino, offset);
|
||||||
|
|
||||||
let path = match self.get_path(ino) {
|
let path = match self.mapper.get_path(ino) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
reply.error(libc::ENOENT);
|
reply.error(libc::ENOENT);
|
||||||
@@ -192,7 +206,7 @@ impl Filesystem for FurumiFuse {
|
|||||||
format!("{}/{}", path, entry.name)
|
format!("{}/{}", path, entry.name)
|
||||||
};
|
};
|
||||||
|
|
||||||
let child_ino = self.get_inode(&full_path);
|
let child_ino = self.mapper.get_inode(&full_path);
|
||||||
let kind = if entry.r#type == 4 {
|
let kind = if entry.r#type == 4 {
|
||||||
FileType::Directory
|
FileType::Directory
|
||||||
} else {
|
} else {
|
||||||
@@ -226,7 +240,7 @@ impl Filesystem for FurumiFuse {
|
|||||||
) {
|
) {
|
||||||
debug!("read: ino={} offset={} size={}", ino, offset, size);
|
debug!("read: ino={} offset={} size={}", ino, offset, size);
|
||||||
|
|
||||||
let path = match self.get_path(ino) {
|
let path = match self.mapper.get_path(ino) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
reply.error(libc::ENOENT);
|
reply.error(libc::ENOENT);
|
||||||
@@ -263,3 +277,43 @@ impl Filesystem for FurumiFuse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inode_mapper_root() {
|
||||||
|
let mapper = InodeMapper::new();
|
||||||
|
// Root is always 1
|
||||||
|
assert_eq!(mapper.get_path(1).unwrap(), "/");
|
||||||
|
assert_eq!(mapper.get_inode("/"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inode_mapper_assignment() {
|
||||||
|
let mapper = InodeMapper::new();
|
||||||
|
|
||||||
|
// New paths get new inodes starting from 2
|
||||||
|
let path1 = "/hello.txt";
|
||||||
|
let ino1 = mapper.get_inode(path1);
|
||||||
|
assert_eq!(ino1, 2);
|
||||||
|
|
||||||
|
let path2 = "/dir/world.mkv";
|
||||||
|
let ino2 = mapper.get_inode(path2);
|
||||||
|
assert_eq!(ino2, 3);
|
||||||
|
|
||||||
|
// Lookup gives the mapped paths back
|
||||||
|
assert_eq!(mapper.get_path(2).unwrap(), path1);
|
||||||
|
assert_eq!(mapper.get_path(3).unwrap(), path2);
|
||||||
|
|
||||||
|
// Asking for the same path again returns the same inode
|
||||||
|
assert_eq!(mapper.get_inode(path1), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_inode_mapper_non_existent() {
|
||||||
|
let mapper = InodeMapper::new();
|
||||||
|
assert!(mapper.get_path(999).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use furumi_client_core::FurumiClient;
|
|||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(version, about = "Furumi-ng: mount remote filesystem via encrypted gRPC + FUSE")]
|
#[command(version, about = "Furumi-ng: mount remote filesystem via encrypted gRPC + FUSE")]
|
||||||
struct Args {
|
struct Args {
|
||||||
/// Server address (use https:// for encrypted, http:// for plaintext)
|
/// Server address (IP:PORT, will use https:// by default unless --no-tls is specified)
|
||||||
#[arg(short, long, env = "FURUMI_SERVER", default_value = "https://0.0.0.0:50051")]
|
#[arg(short, long, env = "FURUMI_SERVER", default_value = "0.0.0.0:50051")]
|
||||||
server: String,
|
server: String,
|
||||||
|
|
||||||
/// Authentication Bearer token (leave empty if auth is disabled on server)
|
/// Authentication Bearer token (leave empty if auth is disabled on server)
|
||||||
@@ -21,6 +21,10 @@ struct Args {
|
|||||||
/// Mount point directory
|
/// Mount point directory
|
||||||
#[arg(short, long, env = "FURUMI_MOUNT")]
|
#[arg(short, long, env = "FURUMI_MOUNT")]
|
||||||
mount: PathBuf,
|
mount: PathBuf,
|
||||||
|
|
||||||
|
/// Disable TLS encryption
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
no_tls: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -38,8 +42,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.enable_all()
|
.enable_all()
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
|
let full_addr = if args.no_tls {
|
||||||
|
if args.server.starts_with("http://") || args.server.starts_with("https://") {
|
||||||
|
args.server.clone()
|
||||||
|
} else {
|
||||||
|
format!("http://{}", args.server)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if args.server.starts_with("http://") || args.server.starts_with("https://") {
|
||||||
|
args.server.clone()
|
||||||
|
} else {
|
||||||
|
format!("https://{}", args.server)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let client = rt.block_on(async {
|
let client = rt.block_on(async {
|
||||||
FurumiClient::connect(&args.server, &args.token).await
|
FurumiClient::connect(&full_addr, &args.token).await
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let fuse_fs = fs::FurumiFuse::new(client, rt.handle().clone());
|
let fuse_fs = fs::FurumiFuse::new(client, rt.handle().clone());
|
||||||
|
|||||||
10
furumi-mount-linux/src/tests.rs
Normal file
10
furumi-mount-linux/src/tests.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use crate::fs::FurumiFuse;
|
||||||
|
use furumi_client_core::FurumiClient;
|
||||||
|
|
||||||
|
// We need a helper to create a dummy client since FurumiFuse requires one.
|
||||||
|
// The easiest way is to connect to a non-existent port with HTTP, which fails fast,
|
||||||
|
// but for FurumiFuse::new, it just needs the struct, not an active connection yet.
|
||||||
|
// Wait, `connect` returns a Result. Since we don't mock the gRPC server here,
|
||||||
|
// testing FurumiFuse without a real client is hard because FurumiFuse has `client: FurumiClient`.
|
||||||
|
// Instead, we can refactor the Inode mapper into a separate struct to make it easily testable.
|
||||||
|
|
||||||
@@ -126,3 +126,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|||||||
109
furumi-server/src/tests.rs
Normal file
109
furumi-server/src/tests.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use crate::metrics;
|
||||||
|
use crate::security::AuthInterceptor;
|
||||||
|
use tonic::service::Interceptor;
|
||||||
|
use tonic::Request;
|
||||||
|
// Since counters are lazy statics, we can get their values directly for testing
|
||||||
|
use std::thread::sleep;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metrics_request_timer_ok() {
|
||||||
|
let method = "test_method_ok";
|
||||||
|
let timer = metrics::RequestTimer::new(method);
|
||||||
|
|
||||||
|
// Simulate some work
|
||||||
|
sleep(Duration::from_millis(10));
|
||||||
|
timer.finish_ok();
|
||||||
|
|
||||||
|
// Check counters
|
||||||
|
let started = metrics::GRPC_REQUESTS_TOTAL
|
||||||
|
.with_label_values(&[method, "started"])
|
||||||
|
.get();
|
||||||
|
let ok = metrics::GRPC_REQUESTS_TOTAL
|
||||||
|
.with_label_values(&[method, "ok"])
|
||||||
|
.get();
|
||||||
|
|
||||||
|
assert_eq!(started, 1.0);
|
||||||
|
assert_eq!(ok, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metrics_request_timer_err() {
|
||||||
|
let method = "test_method_err";
|
||||||
|
let timer = metrics::RequestTimer::new(method);
|
||||||
|
|
||||||
|
// Simulate some work
|
||||||
|
sleep(Duration::from_millis(5));
|
||||||
|
timer.finish_err();
|
||||||
|
|
||||||
|
// Check counters
|
||||||
|
let started = metrics::GRPC_REQUESTS_TOTAL
|
||||||
|
.with_label_values(&[method, "started"])
|
||||||
|
.get();
|
||||||
|
let err = metrics::GRPC_REQUESTS_TOTAL
|
||||||
|
.with_label_values(&[method, "error"])
|
||||||
|
.get();
|
||||||
|
|
||||||
|
assert_eq!(started, 1.0);
|
||||||
|
assert_eq!(err, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metrics_render() {
|
||||||
|
// Just trigger a metric to ensure the registry isn't empty
|
||||||
|
metrics::BYTES_READ_TOTAL.inc_by(4096.0);
|
||||||
|
|
||||||
|
let rendered = metrics::render_metrics();
|
||||||
|
assert!(rendered.contains("furumi_bytes_read_total"));
|
||||||
|
assert!(rendered.contains("4096"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_server_auth_interceptor_valid() {
|
||||||
|
let mut interceptor = AuthInterceptor::new("supersecret".to_string());
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Bearer supersecret".parse().unwrap());
|
||||||
|
|
||||||
|
let res = interceptor.call(req);
|
||||||
|
assert!(res.is_ok(), "Valid token should be accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_server_auth_interceptor_invalid() {
|
||||||
|
let mut interceptor = AuthInterceptor::new("supersecret".to_string());
|
||||||
|
let mut req = Request::new(());
|
||||||
|
req.metadata_mut()
|
||||||
|
.insert("authorization", "Bearer wrongtoken".parse().unwrap());
|
||||||
|
|
||||||
|
let res = interceptor.call(req);
|
||||||
|
assert!(res.is_err(), "Invalid token should be rejected");
|
||||||
|
assert_eq!(res.unwrap_err().code(), tonic::Code::Unauthenticated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_server_auth_interceptor_missing() {
|
||||||
|
let mut interceptor = AuthInterceptor::new("supersecret".to_string());
|
||||||
|
let req = Request::new(());
|
||||||
|
// Missing metadata entirely
|
||||||
|
|
||||||
|
let res = interceptor.call(req);
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"Missing token should be rejected when auth is enabled"
|
||||||
|
);
|
||||||
|
assert_eq!(res.unwrap_err().code(), tonic::Code::Unauthenticated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_server_auth_interceptor_disabled() {
|
||||||
|
// Empty string means auth is disabled
|
||||||
|
let mut interceptor = AuthInterceptor::new("".to_string());
|
||||||
|
let req = Request::new(());
|
||||||
|
|
||||||
|
let res = interceptor.call(req);
|
||||||
|
assert!(
|
||||||
|
res.is_ok(),
|
||||||
|
"Request should pass if auth is disabled on server"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user