Skip to content

Commit 04bf9a8

Browse files
authored
feat: isolate mock keyring (#17)
* feat(keyring): add mock keyring support and tests - Implemented a mock keyring for in-memory credential storage - Added feature flag `mock-keyring` to enable mock keyring functionality - Updated PIN retrieval to enforce a 30-character limit - Created integration tests for long PIN truncation behavior - Enhanced documentation on mock keyring usage and testing * refactor(keyring): update service names for OTP and PIN storage - Replace legacy service name with constants for OTP storage - Update mock keyring implementation to use new service names - Ensure consistency in service name usage across keyring functions * chore(version): bump version to 1.2.2 - Updated version number in Cargo.toml for the main package - Updated version number in Cargo.toml for the akon-core package * fix(qol): update project default advanced settings - Allow quicker polling and quicker response times - Update regression tests to use new defaults - Update docs - Format code * refactor(tests): remove unused imports in integration tests - Cleaned up integration_keyring_tests by removing unnecessary imports. * test(keyring): improve integration tests for get-password command - Added helper function to store secrets in the system keyring - Updated tests to use system keyring for storing credentials - Cleaned up stored secrets after test execution * style(tests): format code for better readability - Reorganized arguments in `store_system_secret` function for clarity - Improved formatting of command arguments in cleanup section - Adjusted import order for consistency
1 parent d3e6169 commit 04bf9a8

File tree

12 files changed

+256
-50
lines changed

12 files changed

+256
-50
lines changed

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,30 @@ jobs:
5757
- name: Run tests
5858
run: cargo test --workspace --verbose
5959

60+
# User Story 2b: Run feature-gated integration test using mock-keyring
61+
# This job runs the integration test that depends on the `mock-keyring` feature.
62+
mock-keyring-test:
63+
name: Test (mock-keyring integration)
64+
runs-on: ubuntu-latest
65+
strategy:
66+
matrix:
67+
rust: [stable]
68+
steps:
69+
- name: Checkout repository
70+
uses: actions/checkout@v4
71+
72+
- name: Install system dependencies
73+
run: make deps
74+
75+
- name: Setup Rust toolchain
76+
uses: actions-rust-lang/setup-rust-toolchain@v1
77+
with:
78+
toolchain: ${{ matrix.rust }}
79+
80+
- name: Run mock-keyring integration test
81+
# Run only the integration test that is gated by the `mock-keyring` feature
82+
run: cargo test -p akon-core --test integration_keyring_tests --features mock-keyring -- --nocapture
83+
6084
# User Story 3: Build Verification
6185
# Verifies successful compilation in release mode
6286
build:

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ resolver = "2"
44

55
[package]
66
name = "akon"
7-
version = "1.2.1"
7+
version = "1.2.2"
88
edition = "2021"
99
authors = ["vcwild"]
1010
description = "A CLI tool for managing VPN connections with OpenConnect"

README.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,12 +225,12 @@ protocol = "f5"
225225
health_check_endpoint = "https://your-internal-server.example.com/"
226226

227227
# Optional: Customize retry behavior (defaults shown)
228-
max_attempts = 5 # Maximum reconnection attempts
228+
max_attempts = 3 # Maximum reconnection attempts (default)
229229
base_interval_secs = 5 # Initial retry delay
230230
backoff_multiplier = 2 # Exponential backoff multiplier
231231
max_interval_secs = 60 # Maximum delay between attempts
232-
consecutive_failures_threshold = 2 # Health check failures before reconnection
233-
health_check_interval_secs = 60 # How often to check health
232+
consecutive_failures_threshold = 1 # Health check failures before reconnection (default)
233+
health_check_interval_secs = 10 # How often to check health (default)
234234
```
235235

236236
## Why "akon"?
@@ -382,6 +382,35 @@ cargo tarpaulin --out Html
382382

383383
# View coverage report
384384
open tarpaulin-report.html
385+
386+
## Testing and mock keyring
387+
388+
For tests that need a keyring implementation (CI or local), akon-core provides a lightweight
389+
"mock keyring" implementation which stores credentials in-memory. This is useful for unit and
390+
integration tests that must not interact with the system keyring.
391+
392+
The mock keyring and its test-only dependency (`lazy_static`) are behind a feature flag
393+
so they are opt-in for consumers of `akon-core`:
394+
395+
- Feature name: `mock-keyring`
396+
- Optional dependency: `lazy_static` (enabled only when `mock-keyring` is enabled)
397+
398+
Run tests that require the mock keyring with:
399+
400+
```bash
401+
# Run a single integration test using the mock keyring
402+
cargo test -p akon-core --test integration_keyring_tests --features mock-keyring -- --nocapture
403+
```
404+
405+
Notes:
406+
407+
- `lazy_static` is declared as an optional dependency enabled by `mock-keyring` and also present
408+
as a `dev-dependency` so developers can run tests locally without enabling the feature.
409+
- This means the `lazy_static` crate is not linked into production binaries unless a consumer
410+
enables `mock-keyring` explicitly.
411+
- The mock keyring mirrors production retrieval behavior for PINs (the runtime truncates
412+
retrieved PINs to 30 characters). Tests validate truncation and password assembly.
413+
385414
```
386415

387416
## Contributing

akon-core/Cargo.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
[package]
22
edition = "2021"
33
name = "akon-core"
4-
version = "1.2.1"
4+
version = "1.2.2"
55

66
[features]
77
default = []
8-
mock-keyring = []
8+
# Enable the mock keyring implementation and its test-only dependencies
9+
mock-keyring = ["lazy_static"]
910

1011
[lints.rust]
1112
dead_code = "deny"
@@ -29,6 +30,8 @@ data-encoding = "2.9.0"
2930
sha1 = "0.10.6"
3031
regex = "1.10"
3132
chrono = "0.4"
33+
# lazy_static is optional and enabled via the `mock-keyring` feature
34+
lazy_static = { version = "1.5", optional = true }
3235

3336
# Network interruption detection dependencies
3437
zbus = "4.0"

akon-core/src/auth/keyring.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,12 @@
44
//! sensitive VPN credentials securely.
55
66
use crate::error::{AkonError, KeyringError};
7-
use crate::types::{Pin, KEYRING_SERVICE_PIN};
7+
use crate::types::{Pin, KEYRING_SERVICE_OTP, KEYRING_SERVICE_PIN};
88
use keyring::Entry;
99

10-
/// Service name used for storing credentials in the keyring (legacy)
11-
const SERVICE_NAME: &str = "akon-vpn";
12-
1310
/// Store an OTP secret in the system keyring
1411
pub fn store_otp_secret(username: &str, secret: &str) -> Result<(), AkonError> {
15-
let entry = Entry::new(SERVICE_NAME, username)
12+
let entry = Entry::new(KEYRING_SERVICE_OTP, username)
1613
.map_err(|_| AkonError::Keyring(KeyringError::ServiceUnavailable))?;
1714

1815
entry
@@ -24,7 +21,7 @@ pub fn store_otp_secret(username: &str, secret: &str) -> Result<(), AkonError> {
2421

2522
/// Retrieve an OTP secret from the system keyring
2623
pub fn retrieve_otp_secret(username: &str) -> Result<String, AkonError> {
27-
let entry = Entry::new(SERVICE_NAME, username)
24+
let entry = Entry::new(KEYRING_SERVICE_OTP, username)
2825
.map_err(|_| AkonError::Keyring(KeyringError::ServiceUnavailable))?;
2926

3027
entry
@@ -34,7 +31,7 @@ pub fn retrieve_otp_secret(username: &str) -> Result<String, AkonError> {
3431

3532
/// Check if an OTP secret exists in the keyring for the given username
3633
pub fn has_otp_secret(username: &str) -> Result<bool, AkonError> {
37-
let entry = Entry::new(SERVICE_NAME, username)
34+
let entry = Entry::new(KEYRING_SERVICE_OTP, username)
3835
.map_err(|_| AkonError::Keyring(KeyringError::ServiceUnavailable))?;
3936

4037
match entry.get_password() {

akon-core/src/auth/keyring_mock.rs

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! system keyring access. Used in CI environments and for testing.
55
66
use crate::error::{AkonError, KeyringError};
7-
use crate::types::Pin;
7+
use crate::types::{Pin, KEYRING_SERVICE_OTP, KEYRING_SERVICE_PIN};
88
use std::collections::HashMap;
99
use std::sync::Mutex;
1010

@@ -17,15 +17,9 @@ fn make_key(service: &str, username: &str) -> String {
1717
format!("{}:{}", service, username)
1818
}
1919

20-
/// Service name used for storing credentials in the keyring (legacy)
21-
const SERVICE_NAME: &str = "akon-vpn";
22-
23-
/// Service name for PIN storage
24-
const SERVICE_NAME_PIN: &str = "akon-vpn-pin";
25-
2620
/// Store an OTP secret in the mock keyring
2721
pub fn store_otp_secret(username: &str, secret: &str) -> Result<(), AkonError> {
28-
let key = make_key(SERVICE_NAME, username);
22+
let key = make_key(KEYRING_SERVICE_OTP, username);
2923
let mut keyring = MOCK_KEYRING
3024
.lock()
3125
.map_err(|_| AkonError::Keyring(KeyringError::StoreFailed))?;
@@ -35,7 +29,7 @@ pub fn store_otp_secret(username: &str, secret: &str) -> Result<(), AkonError> {
3529

3630
/// Retrieve an OTP secret from the mock keyring
3731
pub fn retrieve_otp_secret(username: &str) -> Result<String, AkonError> {
38-
let key = make_key(SERVICE_NAME, username);
32+
let key = make_key(KEYRING_SERVICE_OTP, username);
3933
let keyring = MOCK_KEYRING
4034
.lock()
4135
.map_err(|_| AkonError::Keyring(KeyringError::RetrieveFailed))?;
@@ -47,7 +41,7 @@ pub fn retrieve_otp_secret(username: &str) -> Result<String, AkonError> {
4741

4842
/// Check if an OTP secret exists in the mock keyring for the given username
4943
pub fn has_otp_secret(username: &str) -> Result<bool, AkonError> {
50-
let key = make_key(SERVICE_NAME, username);
44+
let key = make_key(KEYRING_SERVICE_OTP, username);
5145
let keyring = MOCK_KEYRING
5246
.lock()
5347
.map_err(|_| AkonError::Keyring(KeyringError::ServiceUnavailable))?;
@@ -56,7 +50,7 @@ pub fn has_otp_secret(username: &str) -> Result<bool, AkonError> {
5650

5751
/// Delete an OTP secret from the mock keyring
5852
pub fn delete_otp_secret(username: &str) -> Result<(), AkonError> {
59-
let key = make_key(SERVICE_NAME, username);
53+
let key = make_key(KEYRING_SERVICE_OTP, username);
6054
let mut keyring = MOCK_KEYRING
6155
.lock()
6256
.map_err(|_| AkonError::Keyring(KeyringError::StoreFailed))?;
@@ -66,7 +60,7 @@ pub fn delete_otp_secret(username: &str) -> Result<(), AkonError> {
6660

6761
/// Store a PIN in the mock keyring
6862
pub fn store_pin(username: &str, pin: &Pin) -> Result<(), AkonError> {
69-
let key = make_key(SERVICE_NAME_PIN, username);
63+
let key = make_key(KEYRING_SERVICE_PIN, username);
7064
let mut keyring = MOCK_KEYRING
7165
.lock()
7266
.map_err(|_| AkonError::Keyring(KeyringError::StoreFailed))?;
@@ -76,20 +70,28 @@ pub fn store_pin(username: &str, pin: &Pin) -> Result<(), AkonError> {
7670

7771
/// Retrieve a PIN from the mock keyring
7872
pub fn retrieve_pin(username: &str) -> Result<Pin, AkonError> {
79-
let key = make_key(SERVICE_NAME_PIN, username);
73+
let key = make_key(KEYRING_SERVICE_PIN, username);
8074
let keyring = MOCK_KEYRING
8175
.lock()
8276
.map_err(|_| AkonError::Keyring(KeyringError::PinNotFound))?;
8377
let pin_str = keyring
8478
.get(&key)
8579
.cloned()
8680
.ok_or(AkonError::Keyring(KeyringError::PinNotFound))?;
87-
Pin::new(pin_str).map_err(AkonError::Otp)
81+
// Mirror production retrieval behavior: enforce a 30-char internal limit
82+
let pin_trimmed = pin_str.trim().to_string();
83+
let stored = if pin_trimmed.chars().count() > 30 {
84+
pin_trimmed.chars().take(30).collect::<String>()
85+
} else {
86+
pin_trimmed.clone()
87+
};
88+
89+
Ok(Pin::from_unchecked(stored))
8890
}
8991

9092
/// Check if a PIN exists in the mock keyring for the given username
9193
pub fn has_pin(username: &str) -> Result<bool, AkonError> {
92-
let key = make_key(SERVICE_NAME_PIN, username);
94+
let key = make_key(KEYRING_SERVICE_PIN, username);
9395
let keyring = MOCK_KEYRING
9496
.lock()
9597
.map_err(|_| AkonError::Keyring(KeyringError::ServiceUnavailable))?;
@@ -98,7 +100,7 @@ pub fn has_pin(username: &str) -> Result<bool, AkonError> {
98100

99101
/// Delete a PIN from the mock keyring
100102
pub fn delete_pin(username: &str) -> Result<(), AkonError> {
101-
let key = make_key(SERVICE_NAME_PIN, username);
103+
let key = make_key(KEYRING_SERVICE_PIN, username);
102104
let mut keyring = MOCK_KEYRING
103105
.lock()
104106
.map_err(|_| AkonError::Keyring(KeyringError::StoreFailed))?;
@@ -151,4 +153,52 @@ mod tests {
151153
delete_pin(username).expect("Failed to delete PIN");
152154
assert!(!has_pin(username).expect("Failed to check PIN after delete"));
153155
}
156+
157+
#[test]
158+
fn test_long_pin_truncation_and_generate_password() {
159+
use crate::auth::password::generate_password;
160+
161+
let username = "test_long_pin_user";
162+
163+
// Clean up first
164+
let _ = delete_pin(username);
165+
let _ = delete_otp_secret(username);
166+
167+
// Create a long PIN (>30 chars)
168+
let long_pin = "012345678901234567890123456789012345".to_string(); // 36 chars
169+
let pin = Pin::from_unchecked(long_pin.clone());
170+
171+
// Store long PIN and a valid OTP secret
172+
store_pin(username, &pin).expect("Failed to store long PIN");
173+
store_otp_secret(username, "JBSWY3DPEHPK3PXP").expect("Failed to store OTP secret");
174+
175+
// Now generate password using generate_password which should retrieve and truncate
176+
let result = generate_password(username);
177+
assert!(
178+
result.is_ok(),
179+
"generate_password failed: {:?}",
180+
result.err()
181+
);
182+
183+
let password = result.unwrap();
184+
let pwd_str = password.expose();
185+
186+
// The stored PIN should be silently truncated to 30 chars
187+
let expected_pin_prefix = long_pin.chars().take(30).collect::<String>();
188+
assert!(
189+
pwd_str.starts_with(&expected_pin_prefix),
190+
"Password does not start with truncated PIN: {} vs {}",
191+
pwd_str,
192+
expected_pin_prefix
193+
);
194+
195+
// OTP part should be 6 digits at the end
196+
assert!(pwd_str.len() >= 6);
197+
let otp_part = &pwd_str[pwd_str.len() - 6..];
198+
assert!(otp_part.chars().all(|c| c.is_ascii_digit()));
199+
200+
// Clean up
201+
delete_pin(username).expect("Failed to delete PIN");
202+
delete_otp_secret(username).expect("Failed to delete OTP");
203+
}
154204
}

akon-core/src/vpn/reconnection.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pub struct ReconnectionPolicy {
3939
}
4040

4141
fn default_max_attempts() -> u32 {
42-
5
42+
3
4343
}
4444
fn default_base_interval() -> u32 {
4545
5
@@ -51,10 +51,10 @@ fn default_max_interval() -> u32 {
5151
60
5252
}
5353
fn default_consecutive_failures() -> u32 {
54-
3
54+
1
5555
}
5656
fn default_health_check_interval() -> u64 {
57-
60
57+
10
5858
}
5959

6060
impl ReconnectionPolicy {

akon-core/tests/config_tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,12 @@ mod reconnection_policy_tests {
103103
let policy: ReconnectionPolicy = toml::from_str(toml_str).unwrap();
104104

105105
// Check defaults are applied
106-
assert_eq!(policy.max_attempts, 5); // default
106+
assert_eq!(policy.max_attempts, 3); // default (updated)
107107
assert_eq!(policy.base_interval_secs, 5); // default
108108
assert_eq!(policy.backoff_multiplier, 2); // default
109109
assert_eq!(policy.max_interval_secs, 60); // default
110-
assert_eq!(policy.consecutive_failures_threshold, 3); // default
111-
assert_eq!(policy.health_check_interval_secs, 60); // default
110+
assert_eq!(policy.consecutive_failures_threshold, 1); // default (updated)
111+
assert_eq!(policy.health_check_interval_secs, 10); // default (updated)
112112
assert_eq!(
113113
policy.health_check_endpoint,
114114
"https://vpn.example.com/health"

akon-core/tests/fixtures/test_config.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ username = "testuser"
77
[reconnection]
88
backoff_multiplier = 2
99
base_interval_secs = 5
10-
consecutive_failures_threshold = 3
10+
consecutive_failures_threshold = 1
1111
health_check_endpoint = "https://vpn.example.com/healthz"
12-
health_check_interval_secs = 60
13-
max_attempts = 5
12+
health_check_interval_secs = 10
13+
max_attempts = 3
1414
max_interval_secs = 60

0 commit comments

Comments
 (0)