rtb-credentials 0.6.4

OS keychain credential storage. Part of the phpboyscout Rust toolkit.
Documentation
---
title: Test code that resolves credentials
description: Use MemoryStore to test credential resolution without touching a real keychain, and avoid the env-var races that make such tests flaky.
tags:
  - how-to
  - testing
---

# Test code that resolves credentials

`MemoryStore` is exported from the crate root, not hidden behind a
`test` feature, so downstream test suites can use it directly.

## Inject a `MemoryStore` instead of the platform keychain

```rust
use rtb_credentials::{CredentialStore, MemoryStore, Resolver, SecretString};
use std::sync::Arc;

#[tokio::test]
async fn resolves_from_the_keychain_leg() {
    let store = Arc::new(MemoryStore::new());
    store
        .set("mytool", "anthropic", SecretString::from("test-secret".to_string()))
        .await
        .unwrap();

    let resolver = Resolver::new(store);
    // ...
}
```

Write production code that takes `Arc<dyn CredentialStore>` rather than
calling `Resolver::with_platform_default()` internally. That one choice
is what makes the code testable — `with_platform_default` hard-wires
`KeyringStore` and cannot be substituted.

## Assert on the secret with `expose_secret`

```rust
use rtb_credentials::ExposeSecret;

let secret = resolver.resolve(&cref).await.unwrap();
assert_eq!(secret.expose_secret(), "test-secret");
```

`SecretString` does not implement `PartialEq`, so comparing two
`SecretString`s directly does not compile. Compare the exposed `&str`.

## Build a `CredentialRef` without deserialising config

All four fields are public, and `Default` supplies an empty reference:

```rust
use rtb_credentials::{CredentialRef, KeychainRef};

let cref = CredentialRef {
    keychain: Some(KeychainRef {
        service: "mytool".to_string(),
        account: "anthropic".to_string(),
    }),
    ..CredentialRef::default()
};
```

## Avoid environment variables in parallel tests

Rust runs tests in threads within one process, so `set_var` and
`remove_var` are shared mutable state across every test in the binary. A
test that sets `CI`, or a variable another test reads, will
intermittently corrupt its neighbours.

Two rules keep this manageable:

**Use a unique variable name per test.** The crate's own suite does
exactly this — `RTBCRED_T4_VAR`, `RTBCRED_T8_ENV` — so no two tests
contend for the same name.

**Prefer the keychain leg.** A `MemoryStore` is per-test state with no
global reach, so a test exercising resolution through the keychain leg
needs no environment mutation at all. Reach for env vars only when the
env legs are what you are testing.

`CI` is the one name that cannot be made unique, since the crate reads
that specific variable. A test that sets it must restore the previous
value:

```rust
let prior = std::env::var("CI").ok();
// ... set CI, run the assertion ...
match prior {
    Some(v) => unsafe { std::env::set_var("CI", v) },
    None => unsafe { std::env::remove_var("CI") },
}
```

Even restored, such a test races with any concurrent test that resolves
a literal. Consider keeping the CI-refusal test in its own test binary.

## Do not use `LiteralStore` as a test keychain

`LiteralStore::get` returns its one secret for **every** service and
account, so a test using it cannot detect a lookup with the wrong
identifiers — every key appears to exist.

`MemoryStore` returns `NotFound` for keys that were not set, which is
what makes a negative assertion meaningful.

## Assert on the failure paths

The behaviours most likely to surprise a user are the ones worth
pinning:

```rust
use rtb_credentials::CredentialError;

// nothing configured
let empty = CredentialRef::default();
assert!(matches!(
    resolver.resolve(&empty).await,
    Err(CredentialError::NotFound { .. })
));
```

A store returning a `Keychain` error rather than `NotFound` aborts the
whole chain, so a test that a fallback takes over should use a store
returning `NotFound` — which `MemoryStore` does for any key it was not
given.