Skip to main content

alibi_api/plugins/api_key/
mod.rs

1mod cleanup;
2mod config;
3
4pub use config::ApiKeyConfig;
5pub use config::ApiKeyReferences;
6pub use config::KeyExpirationConfig;
7pub use config::RateLimitDefaults;
8mod callbacks;
9mod secondary_usage;
10mod storage;
11pub use storage::{ApiKeyStorage, ApiKeyStorageMode};
12mod endpoint;
13pub use endpoint::{ApiKeyVerificationInput, ApiKeyVerificationOutput};
14
15pub(super) mod handlers;
16
17pub(super) mod types;
18
19mod verification;
20
21use alibi_core::entity::AuthUser;
22use alibi_core::{AuthContext, AuthError, AuthResult, BeforeRequestAction};
23use alibi_core::{AuthRequest, AuthResponse};
24use base64::Engine;
25use base64::engine::general_purpose::URL_SAFE_NO_PAD;
26pub use callbacks::{
27    ApiKeyCallbackContext, ApiKeyDefaultPermissions, ApiKeyGenerationOptions, ApiKeyGenerator,
28    ApiKeyGetter, ApiKeyPermissions, ApiKeyValidator,
29};
30use handlers::{create_key_core, delete_key_core, get_key_core, list_keys_core, update_key_core};
31use rand::seq::IndexedRandom;
32use sha2::{Digest, Sha256};
33use std::sync::{Arc, Mutex};
34pub use types::{
35    CreateKeyRequest, CreateKeyResponse, DeleteExpiredApiKeysResponse, UpdateKeyRequest,
36};
37use types::{DeleteKeyRequest, ListKeysQuery, parse_api_key_body};
38pub use verification::{
39    ApiKeyErrorDetails, ApiKeyErrorMessage, ApiKeyValidationError, ApiKeyVerificationError,
40    VerifyApiKey,
41};
42
43// ---------------------------------------------------------------------------
44// Error codes -- mirrors the TypeScript `API_KEY_ERROR_CODES`
45// ---------------------------------------------------------------------------
46
47/// Dedicated API Key error codes aligned with the TypeScript `API_KEY_ERROR_CODES`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ApiKeyErrorCode {
50    InvalidApiKey,
51    KeyDisabled,
52    KeyExpired,
53    UsageExceeded,
54    KeyNotFound,
55    RateLimited,
56    UnauthorizedSession,
57    InvalidPrefixLength,
58    InvalidNameLength,
59    MetadataDisabled,
60    NoValuesToUpdate,
61    KeyDisabledExpiration,
62    ExpiresInTooSmall,
63    ExpiresInTooLarge,
64    InvalidRemaining,
65    RefillAmountAndIntervalRequired,
66    RefillIntervalAndAmountRequired,
67    NameRequired,
68    InvalidUserIdFromApiKey,
69    InvalidReferenceIdFromApiKey,
70    NoDefaultConfiguration,
71    OrganizationIdRequired,
72    OrganizationPluginRequired,
73    UserNotMemberOfOrganization,
74    InsufficientApiKeyPermissions,
75    ServerOnlyProperty,
76    FailedToUpdateApiKey,
77    InvalidMetadataType,
78}
79
80impl ApiKeyErrorCode {
81    #[must_use]
82    pub const fn as_str(self) -> &'static str {
83        match self {
84            Self::InvalidApiKey => "INVALID_API_KEY",
85            Self::KeyDisabled => "KEY_DISABLED",
86            Self::KeyExpired => "KEY_EXPIRED",
87            Self::UsageExceeded => "USAGE_EXCEEDED",
88            Self::KeyNotFound => "KEY_NOT_FOUND",
89            Self::RateLimited => "RATE_LIMITED",
90            Self::UnauthorizedSession => "UNAUTHORIZED_SESSION",
91            Self::InvalidPrefixLength => "INVALID_PREFIX_LENGTH",
92            Self::InvalidNameLength => "INVALID_NAME_LENGTH",
93            Self::MetadataDisabled => "METADATA_DISABLED",
94            Self::NoValuesToUpdate => "NO_VALUES_TO_UPDATE",
95            Self::KeyDisabledExpiration => "KEY_DISABLED_EXPIRATION",
96            Self::ExpiresInTooSmall => "EXPIRES_IN_IS_TOO_SMALL",
97            Self::ExpiresInTooLarge => "EXPIRES_IN_IS_TOO_LARGE",
98            Self::InvalidRemaining => "INVALID_REMAINING",
99            Self::RefillAmountAndIntervalRequired => "REFILL_AMOUNT_AND_INTERVAL_REQUIRED",
100            Self::RefillIntervalAndAmountRequired => "REFILL_INTERVAL_AND_AMOUNT_REQUIRED",
101            Self::NameRequired => "NAME_REQUIRED",
102            Self::InvalidUserIdFromApiKey => "INVALID_USER_ID_FROM_API_KEY",
103            Self::InvalidReferenceIdFromApiKey => "INVALID_REFERENCE_ID_FROM_API_KEY",
104            Self::NoDefaultConfiguration => "NO_DEFAULT_API_KEY_CONFIGURATION_FOUND",
105            Self::OrganizationIdRequired => "ORGANIZATION_ID_REQUIRED",
106            Self::OrganizationPluginRequired => "ORGANIZATION_PLUGIN_REQUIRED",
107            Self::UserNotMemberOfOrganization => "USER_NOT_MEMBER_OF_ORGANIZATION",
108            Self::InsufficientApiKeyPermissions => "INSUFFICIENT_API_KEY_PERMISSIONS",
109            Self::ServerOnlyProperty => "SERVER_ONLY_PROPERTY",
110            Self::FailedToUpdateApiKey => "FAILED_TO_UPDATE_API_KEY",
111            Self::InvalidMetadataType => "INVALID_METADATA_TYPE",
112        }
113    }
114
115    #[must_use]
116    pub const fn message(self) -> &'static str {
117        match self {
118            Self::InvalidApiKey => "Invalid API key.",
119            Self::KeyDisabled => "API Key is disabled",
120            Self::KeyExpired => "API Key has expired",
121            Self::UsageExceeded => "API Key has reached its usage limit",
122            Self::KeyNotFound => "API Key not found",
123            Self::RateLimited => "Rate limit exceeded.",
124            Self::UnauthorizedSession => "Unauthorized or invalid session",
125            Self::InvalidPrefixLength => "The prefix length is either too large or too small.",
126            Self::InvalidNameLength => "The name length is either too large or too small.",
127            Self::MetadataDisabled => "Metadata is disabled.",
128            Self::NoValuesToUpdate => "No values to update.",
129            Self::KeyDisabledExpiration => "Custom key expiration values are disabled.",
130            Self::ExpiresInTooSmall => {
131                "The expiresIn is smaller than the predefined minimum value."
132            }
133            Self::ExpiresInTooLarge => "The expiresIn is larger than the predefined maximum value.",
134            Self::InvalidRemaining => "The remaining count is either too large or too small.",
135            Self::RefillAmountAndIntervalRequired => {
136                "refillAmount is required when refillInterval is provided"
137            }
138            Self::RefillIntervalAndAmountRequired => {
139                "refillInterval is required when refillAmount is provided"
140            }
141            Self::NameRequired => "API Key name is required.",
142            Self::InvalidUserIdFromApiKey => "The user id from the API key is invalid.",
143            Self::InvalidReferenceIdFromApiKey => "The reference id from the API key is invalid.",
144            Self::NoDefaultConfiguration => "No default api-key configuration found.",
145            Self::OrganizationIdRequired => {
146                "Organization ID is required for organization-owned API keys."
147            }
148            Self::OrganizationPluginRequired => {
149                "Organization plugin is required for organization-owned API keys. Please install and configure the organization plugin."
150            }
151            Self::UserNotMemberOfOrganization => {
152                "You are not a member of the organization that owns this API key."
153            }
154            Self::InsufficientApiKeyPermissions => {
155                "You do not have permission to perform this action on organization API keys."
156            }
157            Self::ServerOnlyProperty => {
158                "The property you're trying to set can only be set from the server auth instance only."
159            }
160            Self::FailedToUpdateApiKey => "Failed to update API key",
161            Self::InvalidMetadataType => "metadata must be an object or undefined",
162        }
163    }
164}
165
166impl serde::Serialize for ApiKeyErrorCode {
167    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
168        serializer.serialize_str(self.as_str())
169    }
170}
171
172// Pinned api-key module state is shared by all plugin instances and databases.
173static LAST_EXPIRED_CHECK: Mutex<Option<i64>> = Mutex::new(None);
174
175/// API Key management plugin.
176#[derive(Clone)]
177pub struct ApiKeyPlugin {
178    /// Registered configurations. Unscoped requests use the default configuration.
179    pub(super) configurations: Vec<ApiKeyConfig>,
180}
181
182impl ApiKeyPlugin {
183    /// Register an additional named configuration.
184    ///
185    /// Upstream allows several api-key configurations side by side, each with
186    /// its own `config_id`, ownership model and limits.
187    #[must_use]
188    pub fn configuration(mut self, config: ApiKeyConfig) -> Self {
189        self.configurations.push(config.normalized());
190        self
191    }
192
193    /// Pick the configuration a request addressed, mirroring upstream's
194    /// `resolveConfiguration`: an unknown or absent `config_id` falls back to
195    /// the default one, and a missing default is a client error.
196    ///
197    /// # Errors
198    /// Returns an error when validation, storage, or an application callback fails.
199    pub(super) fn resolve_configuration(
200        &self,
201        config_id: Option<&str>,
202    ) -> AuthResult<&ApiKeyConfig> {
203        if let Some(config_id) = config_id
204            && let Some(found) = self
205                .configurations
206                .iter()
207                .find(|config| config.config_id == config_id)
208        {
209            return Ok(found);
210        }
211
212        self.configurations
213            .iter()
214            .find(|config| is_default_config_id(&config.config_id))
215            .ok_or_else(|| api_key_error(ApiKeyErrorCode::NoDefaultConfiguration))
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Plugin implementation
221// ---------------------------------------------------------------------------
222
223/// Builder for [`ApiKeyPlugin`] powered by the `bon` crate.
224///
225/// Usage:
226/// ```
227/// use alibi_api::plugins::api_key::{ApiKeyPlugin, RateLimitDefaults};
228/// let plugin = ApiKeyPlugin::builder()
229///     .key_length(48)
230///     .prefix("ba_".to_string())
231///     .enable_metadata(true)
232///     .rate_limit(RateLimitDefaults { enabled: true, time_window: 60_000.0, max_requests: 5.0 })
233///     .build();
234/// ```
235#[bon::bon]
236impl ApiKeyPlugin {
237    #[builder]
238    #[must_use]
239    pub fn new(
240        #[builder(default = "default".to_owned())] config_id: String,
241        #[builder(default)] references: ApiKeyReferences,
242        #[builder(default)] storage: ApiKeyStorageMode,
243        #[builder(default)] fallback_to_database: bool,
244        custom_storage: Option<Arc<dyn ApiKeyStorage>>,
245        secondary_storage: Option<Arc<dyn alibi_core::store::CacheAdapter>>,
246        #[builder(default = 64.0, into)] key_length: f64,
247        prefix: Option<String>,
248        default_permissions: Option<ApiKeyPermissions>,
249        custom_key_generator: Option<Arc<dyn ApiKeyGenerator>>,
250        default_permissions_callback: Option<Arc<dyn ApiKeyDefaultPermissions>>,
251        #[builder(default = vec!["x-api-key".to_owned()])] api_key_headers: Vec<String>,
252        custom_api_key_getter: Option<Arc<dyn ApiKeyGetter>>,
253        custom_api_key_validator: Option<Arc<dyn ApiKeyValidator>>,
254        #[builder(default = false)] disable_key_hashing: bool,
255        #[builder(default = 6.0, into)] starting_characters_length: f64,
256        #[builder(default = true)] store_starting_characters: bool,
257        #[builder(default = 32.0, into)] max_prefix_length: f64,
258        #[builder(default = 1.0, into)] min_prefix_length: f64,
259        #[builder(default = 32.0, into)] max_name_length: f64,
260        #[builder(default = 1.0, into)] min_name_length: f64,
261        #[builder(default = false)] require_name: bool,
262        #[builder(default = false)] enable_metadata: bool,
263        #[builder(default)] key_expiration: KeyExpirationConfig,
264        #[builder(default)] rate_limit: RateLimitDefaults,
265        #[builder(default = false)] enable_session_for_api_keys: bool,
266        #[builder(default = false)] defer_updates: bool,
267    ) -> Self {
268        Self {
269            configurations: vec![
270                ApiKeyConfig {
271                    config_id,
272                    references,
273                    storage,
274                    fallback_to_database,
275                    custom_storage,
276                    secondary_storage,
277                    key_length,
278                    prefix,
279                    default_permissions,
280                    custom_key_generator,
281                    default_permissions_callback,
282                    api_key_headers,
283                    custom_api_key_getter,
284                    custom_api_key_validator,
285                    disable_key_hashing,
286                    starting_characters_length,
287                    store_starting_characters,
288                    max_prefix_length,
289                    min_prefix_length,
290                    max_name_length,
291                    min_name_length,
292                    require_name,
293                    enable_metadata,
294                    key_expiration,
295                    rate_limit,
296                    enable_session_for_api_keys,
297                    defer_updates,
298                }
299                .normalized(),
300            ],
301        }
302    }
303
304    #[must_use]
305    pub fn with_config(config: ApiKeyConfig) -> Self {
306        Self {
307            configurations: vec![config.normalized()],
308        }
309    }
310
311    // -- internal helpers --
312
313    pub(super) fn generate_key(
314        config: &ApiKeyConfig,
315        custom_prefix: Option<&str>,
316    ) -> AuthResult<(String, String, alibi_core::ApiKeyStartingCharacters)> {
317        if config.key_length <= 0.0 {
318            return Err(AuthError::internal("Length must be a positive integer."));
319        }
320        // Source's zero-byte random buffer cannot advance positive lengths
321        // below one half. Nonfinite built-in lengths also cannot terminate.
322        if !config.key_length.is_finite() || config.key_length < 0.5 {
323            return Err(AuthError::internal("Unsupported random key length"));
324        }
325        let capacity = config
326            .key_length
327            .ceil()
328            .to_string()
329            .parse::<u32>()
330            .map_err(|error| AuthError::internal(error.to_string()))?;
331        // Match TS: generateRandomString(length, "a-z", "A-Z") — alpha only
332        const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
333        let mut rng = rand::rng();
334        let mut raw = String::new();
335        raw.try_reserve_exact(
336            usize::try_from(capacity).map_err(|error| AuthError::internal(error.to_string()))?,
337        )
338        .map_err(|error| AuthError::internal(error.to_string()))?;
339        while f64::from(
340            u32::try_from(raw.len()).map_err(|error| AuthError::internal(error.to_string()))?,
341        ) < config.key_length
342        {
343            raw.push(ALPHABET.choose(&mut rng).copied().map_or('a', char::from));
344        }
345
346        let prefix = custom_prefix
347            .filter(|prefix| !prefix.is_empty())
348            .or(config.prefix.as_deref())
349            .unwrap_or("");
350        let full_key = format!("{prefix}{raw}");
351
352        // TS computes start from the full key (including prefix):
353        //   start = key.substring(0, charactersLength)
354        let start = Self::starting_characters(&full_key, config.starting_characters_length);
355
356        let hash = if config.disable_key_hashing {
357            full_key.clone()
358        } else {
359            Self::hash_key(&full_key)
360        };
361
362        Ok((full_key, hash, start))
363    }
364
365    pub(super) fn starting_characters(
366        key: &str,
367        length: f64,
368    ) -> alibi_core::ApiKeyStartingCharacters {
369        // substring(0, length) clamps negatives/NaN to zero, truncates finite
370        // fractions, and preserves all code units for positive infinity.
371        let end = if length.is_nan() || length < 0.0 {
372            0.0
373        } else {
374            length.trunc()
375        };
376        let units = key
377            .encode_utf16()
378            .scan(0_u32, |position, unit| {
379                let keep = f64::from(*position) < end;
380                *position = position.saturating_add(1);
381                keep.then_some(unit)
382            })
383            .collect();
384        alibi_core::ApiKeyStartingCharacters::from_utf16(units)
385    }
386
387    pub(super) fn hash_key(key: &str) -> String {
388        let mut hasher = Sha256::new();
389        hasher.update(key.as_bytes());
390        let digest = hasher.finalize();
391        URL_SAFE_NO_PAD.encode(digest)
392    }
393
394    // -- Validation helpers --
395
396    ///
397    /// # Errors
398    /// Returns an error when validation, storage, or an application callback fails.
399    pub(super) fn validate_prefix(config: &ApiKeyConfig, prefix: Option<&str>) -> AuthResult<()> {
400        if let Some(p) = prefix.filter(|prefix| !prefix.is_empty()) {
401            let len = f64::from(
402                u32::try_from(p.encode_utf16().count())
403                    .map_err(|error| AuthError::internal(error.to_string()))?,
404            );
405            if len < config.min_prefix_length || len > config.max_prefix_length {
406                return Err(api_key_error(ApiKeyErrorCode::InvalidPrefixLength));
407            }
408        }
409        Ok(())
410    }
411
412    /// Validate the `name` field.
413    ///
414    /// When `is_create` is true, `require_name` is enforced (name must be
415    /// present).  On updates `require_name` is **not** enforced -- the
416    /// caller may be updating unrelated fields without resending the name.
417    ///
418    /// # Errors
419    /// Returns an error when validation, storage, or an application callback fails.
420    pub(super) fn validate_name(
421        config: &ApiKeyConfig,
422        name: Option<&str>,
423        is_create: bool,
424    ) -> AuthResult<()> {
425        if is_create && config.require_name && name.is_none_or(str::is_empty) {
426            return Err(api_key_error(ApiKeyErrorCode::NameRequired));
427        }
428        if let Some(n) = name.filter(|name| !is_create || !name.is_empty()) {
429            let len = f64::from(
430                u32::try_from(n.encode_utf16().count())
431                    .map_err(|error| AuthError::internal(error.to_string()))?,
432            );
433            if len < config.min_name_length || len > config.max_name_length {
434                return Err(api_key_error(ApiKeyErrorCode::InvalidNameLength));
435            }
436        }
437        Ok(())
438    }
439
440    ///
441    /// # Errors
442    /// Returns an error when validation, storage, or an application callback fails.
443    pub(super) fn validate_expires_in(
444        config: &ApiKeyConfig,
445        expires_in: Option<f64>,
446    ) -> AuthResult<Option<f64>> {
447        let cfg = &config.key_expiration;
448        if let Some(secs) = expires_in {
449            if cfg.disable_custom_expires_time {
450                return Err(api_key_error(ApiKeyErrorCode::KeyDisabledExpiration));
451            }
452            // expiresIn is in seconds; min/max are in days
453            let days = secs / 86_400.0;
454            if days < cfg.min_expires_in {
455                return Err(api_key_error(ApiKeyErrorCode::ExpiresInTooSmall));
456            }
457            if days > cfg.max_expires_in {
458                return Err(api_key_error(ApiKeyErrorCode::ExpiresInTooLarge));
459            }
460            Ok(Some(secs))
461        } else {
462            Ok(cfg.default_expires_in)
463        }
464    }
465
466    ///
467    /// # Errors
468    /// Returns an error when validation, storage, or an application callback fails.
469    pub(super) fn validate_metadata(
470        config: &ApiKeyConfig,
471        metadata: Option<&alibi_core::utils::json::JsValue>,
472    ) -> AuthResult<()> {
473        if let Some(value) = metadata.filter(|value| match value {
474            alibi_core::utils::json::JsValue::Null => false,
475            alibi_core::utils::json::JsValue::Bool(value) => *value,
476            alibi_core::utils::json::JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
477            alibi_core::utils::json::JsValue::String(value) => !value.is_empty(),
478            alibi_core::utils::json::JsValue::Array(_)
479            | alibi_core::utils::json::JsValue::Object(_) => true,
480        }) {
481            if !config.enable_metadata {
482                return Err(api_key_error(ApiKeyErrorCode::MetadataDisabled));
483            }
484            if !value.is_object() && !value.is_array() {
485                return Err(api_key_error(ApiKeyErrorCode::InvalidMetadataType));
486            }
487        }
488        Ok(())
489    }
490
491    ///
492    /// # Errors
493    /// Returns an error when validation, storage, or an application callback fails.
494    pub(super) fn validate_refill(
495        refill_interval: Option<f64>,
496        refill_amount: Option<f64>,
497    ) -> AuthResult<()> {
498        match (
499            refill_interval.filter(|value| *value != 0.0),
500            refill_amount.filter(|value| *value != 0.0),
501        ) {
502            (None, Some(_)) => Err(api_key_error(
503                ApiKeyErrorCode::RefillAmountAndIntervalRequired,
504            )),
505            (Some(_), None) => Err(api_key_error(
506                ApiKeyErrorCode::RefillIntervalAndAmountRequired,
507            )),
508            _ => Ok(()),
509        }
510    }
511
512    // -----------------------------------------------------------------------
513    // Route handlers
514    // -----------------------------------------------------------------------
515
516    async fn handle_create(
517        &self,
518        req: &AuthRequest,
519        ctx: &AuthContext<impl alibi_core::AuthSchema>,
520    ) -> AuthResult<AuthResponse> {
521        let mut resolution = req.clone();
522        drop(
523            resolution
524                .query
525                .insert("disableCookieCache".into(), "true".into()),
526        );
527        let (user, _session) = ctx
528            .require_cached_session(&resolution)
529            .await
530            .map_err(|error| match error {
531                AuthError::Unauthenticated => api_key_error(ApiKeyErrorCode::UnauthorizedSession),
532                error @ (AuthError::Api { .. }
533                | AuthError::Upstream { .. }
534                | AuthError::BadRequest(_)
535                | AuthError::InvalidRequest(_)
536                | AuthError::Validation(_)
537                | AuthError::InvalidCredentials
538                | AuthError::AuthenticationFailed(_)
539                | AuthError::SessionNotFound
540                | AuthError::Forbidden(_)
541                | AuthError::UserCreationCancelled
542                | AuthError::SessionCreationCancelled
543                | AuthError::BannedUser(_)
544                | AuthError::Unauthorized
545                | AuthError::UserNotFound
546                | AuthError::NotFound(_)
547                | AuthError::Conflict(_)
548                | AuthError::MethodNotAllowed(_)
549                | AuthError::PayloadTooLarge(_)
550                | AuthError::UnprocessableEntity(_)
551                | AuthError::RateLimited { .. }
552                | AuthError::NotImplemented(_)
553                | AuthError::Config(_)
554                | AuthError::Database(_)
555                | AuthError::Serialization(_)
556                | AuthError::Plugin { .. }
557                | AuthError::CallbackFailure(_)
558                | AuthError::Internal(_)
559                | AuthError::Encryption(_)
560                | AuthError::PasswordHash(_)
561                | AuthError::Jwt(_)) => error,
562            })?;
563        let body: CreateKeyRequest = match parse_api_key_body(req) {
564            Ok(v) => v,
565            Err(resp) => return Ok(resp),
566        };
567        let response = match create_key_core(&body, user.id(), self, ctx, Some(req)).await {
568            Ok(response) => response,
569            Err(error)
570                if error.status_code() >= 500
571                    && !matches!(error, AuthError::Upstream { .. } | AuthError::Api { .. }) =>
572            {
573                return Ok(AuthResponse::new(500));
574            }
575            Err(error) => return Err(error),
576        };
577        Ok(AuthResponse::json(200, &response)?)
578    }
579
580    async fn handle_get(
581        &self,
582        req: &AuthRequest,
583        ctx: &AuthContext<impl alibi_core::AuthSchema>,
584    ) -> AuthResult<AuthResponse> {
585        let mut resolution = req.clone();
586        drop(resolution.query.remove("disableCookieCache"));
587        drop(resolution.query.remove("disableRefresh"));
588        let (user, _session) = super::helpers::ordinary_session(&resolution, ctx).await?;
589        let id = req
590            .query
591            .get("id")
592            .ok_or_else(|| AuthError::bad_request("Query parameter 'id' is required"))?;
593        let config_id = req.query.get("configId").map(String::as_str);
594        key_json_response(get_key_core(id, config_id, user.id(), self, ctx).await)
595    }
596
597    async fn handle_list(
598        &self,
599        req: &AuthRequest,
600        ctx: &AuthContext<impl alibi_core::AuthSchema>,
601    ) -> AuthResult<AuthResponse> {
602        let mut resolution = req.clone();
603        drop(resolution.query.remove("disableCookieCache"));
604        drop(resolution.query.remove("disableRefresh"));
605        let (user, _session) = super::helpers::ordinary_session(&resolution, ctx).await?;
606        let query = match ListKeysQuery::from_request(req) {
607            Ok(query) => query,
608            Err(response) => return Ok(response),
609        };
610        key_json_response(list_keys_core(user.id(), &query, self, ctx).await)
611    }
612
613    async fn handle_update(
614        &self,
615        req: &AuthRequest,
616        ctx: &AuthContext<impl alibi_core::AuthSchema>,
617    ) -> AuthResult<AuthResponse> {
618        let mut resolution = req.clone();
619        drop(
620            resolution
621                .query
622                .insert("disableCookieCache".into(), "true".into()),
623        );
624        let (user, _session) = ctx
625            .require_cached_session(&resolution)
626            .await
627            .map_err(|error| match error {
628                AuthError::Unauthenticated => api_key_error(ApiKeyErrorCode::UnauthorizedSession),
629                error @ (AuthError::Api { .. }
630                | AuthError::Upstream { .. }
631                | AuthError::BadRequest(_)
632                | AuthError::InvalidRequest(_)
633                | AuthError::Validation(_)
634                | AuthError::InvalidCredentials
635                | AuthError::AuthenticationFailed(_)
636                | AuthError::SessionNotFound
637                | AuthError::Forbidden(_)
638                | AuthError::UserCreationCancelled
639                | AuthError::SessionCreationCancelled
640                | AuthError::BannedUser(_)
641                | AuthError::Unauthorized
642                | AuthError::UserNotFound
643                | AuthError::NotFound(_)
644                | AuthError::Conflict(_)
645                | AuthError::MethodNotAllowed(_)
646                | AuthError::PayloadTooLarge(_)
647                | AuthError::UnprocessableEntity(_)
648                | AuthError::RateLimited { .. }
649                | AuthError::NotImplemented(_)
650                | AuthError::Config(_)
651                | AuthError::Database(_)
652                | AuthError::Serialization(_)
653                | AuthError::Plugin { .. }
654                | AuthError::CallbackFailure(_)
655                | AuthError::Internal(_)
656                | AuthError::Encryption(_)
657                | AuthError::PasswordHash(_)
658                | AuthError::Jwt(_)) => error,
659            })?;
660        let body: UpdateKeyRequest = match parse_api_key_body(req) {
661            Ok(v) => v,
662            Err(resp) => return Ok(resp),
663        };
664        let response = update_key_core(&body, user.id(), self, ctx).await?;
665        Ok(AuthResponse::json(200, &response)?)
666    }
667
668    async fn handle_delete(
669        &self,
670        req: &AuthRequest,
671        ctx: &AuthContext<impl alibi_core::AuthSchema>,
672    ) -> AuthResult<AuthResponse> {
673        let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
674        if user.banned() {
675            return Err(AuthError::authentication_failed("User is banned"));
676        }
677        let body: DeleteKeyRequest = match parse_api_key_body(req) {
678            Ok(v) => v,
679            Err(resp) => return Ok(resp),
680        };
681        let response = delete_key_core(&body, user.id(), self, ctx).await?;
682        Ok(AuthResponse::json(200, &response)?)
683    }
684}
685
686fn key_json_response<T: serde::Serialize>(result: AuthResult<T>) -> AuthResult<AuthResponse> {
687    match result {
688        Ok(response) => Ok(AuthResponse::json(200, &response)?),
689        Err(error)
690            if error.status_code() >= 500
691                && !matches!(error, AuthError::Upstream { .. } | AuthError::Api { .. }) =>
692        {
693            Ok(AuthResponse::new(500))
694        }
695        Err(error) => Err(error),
696    }
697}
698
699// ---------------------------------------------------------------------------
700// AuthPlugin trait implementation
701// ---------------------------------------------------------------------------
702
703alibi_core::impl_auth_plugin! {
704    ApiKeyPlugin, "api-key";
705    routes {
706        post "/api-key/create"                    => handle_create,             "api_key_create";
707        get  "/api-key/get"                       => handle_get,                "api_key_get";
708        post "/api-key/update"                    => handle_update,             "api_key_update";
709        post "/api-key/delete"                    => handle_delete,             "api_key_delete";
710        get  "/api-key/list"                      => handle_list,               "api_key_list";
711    }
712    extra {
713    fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
714        crate::metadata::plugin_metadata(<Self as alibi_core::AuthPlugin<S>>::name(self), &<Self as alibi_core::AuthPlugin<S>>::routes(self))
715    }
716
717        fn openapi_metadata(&self,ctx:&alibi_core::AuthInitContext<S>)->alibi_core::PluginOpenApiMetadata {
718            let mut metadata=crate::metadata::instance_plugin_metadata("api-key",&<Self as alibi_core::AuthPlugin<S>>::routes(self),ctx);
719            let defaults=self.configurations.first().filter(|_|self.configurations.len()==1).map(|config|config.rate_limit.clone()).unwrap_or_default();
720            for model in &mut metadata.models {
721                if model.name!="Apikey" {continue;}
722                for field in &mut model.fields {
723                    let value=match field.name.as_str() {"rateLimitTimeWindow"=>defaults.time_window,"rateLimitMax"=>defaults.max_requests,_=>continue};
724                    if let Some(schema)=field.schema.as_object_mut() {drop(schema.insert("default".into(),serde_json::json!(value)));}
725                }
726            }
727            metadata
728        }
729        async fn on_init(&self, _ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
730            if self.configurations.len() > 1 {
731                let mut ids = std::collections::HashSet::new();
732                for config in &self.configurations {
733                    if config.config_id.is_empty() {
734                        return Err(AuthError::config("configId is required for each API key configuration in the api-key plugin."));
735                    }
736                    if !ids.insert(&config.config_id) {
737                        return Err(AuthError::config("configId must be unique for each API key configuration in the api-key plugin."));
738                    }
739                }
740            }
741            Ok(())
742        }
743
744        fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> { endpoint::definitions() }
745
746    fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> { vec![self] }
747
748    fn validate_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, _ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointInput> { endpoint::validate(call) }
749
750    async fn on_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointResponse> { self.call_endpoint(call, ctx).await }
751
752    async fn before_request(
753            &self,
754            req: &AuthRequest,
755            ctx: &AuthContext<S>,
756        ) -> AuthResult<Option<BeforeRequestAction>> {
757            self.api_key_session(req, ctx).await
758        }
759    }
760}
761
762impl std::fmt::Debug for ApiKeyPlugin {
763    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
764        f.debug_struct("ApiKeyPlugin").finish_non_exhaustive()
765    }
766}
767
768pub(super) const fn api_key_error(code: ApiKeyErrorCode) -> AuthError {
769    let status = match code {
770        ApiKeyErrorCode::UnauthorizedSession => 401,
771        ApiKeyErrorCode::OrganizationPluginRequired => 500,
772        ApiKeyErrorCode::UserNotMemberOfOrganization
773        | ApiKeyErrorCode::InsufficientApiKeyPermissions => 403,
774        ApiKeyErrorCode::InvalidApiKey
775        | ApiKeyErrorCode::KeyDisabled
776        | ApiKeyErrorCode::KeyExpired
777        | ApiKeyErrorCode::UsageExceeded
778        | ApiKeyErrorCode::KeyNotFound
779        | ApiKeyErrorCode::RateLimited
780        | ApiKeyErrorCode::InvalidPrefixLength
781        | ApiKeyErrorCode::InvalidNameLength
782        | ApiKeyErrorCode::MetadataDisabled
783        | ApiKeyErrorCode::NoValuesToUpdate
784        | ApiKeyErrorCode::KeyDisabledExpiration
785        | ApiKeyErrorCode::ExpiresInTooSmall
786        | ApiKeyErrorCode::ExpiresInTooLarge
787        | ApiKeyErrorCode::InvalidRemaining
788        | ApiKeyErrorCode::RefillAmountAndIntervalRequired
789        | ApiKeyErrorCode::RefillIntervalAndAmountRequired
790        | ApiKeyErrorCode::NameRequired
791        | ApiKeyErrorCode::InvalidUserIdFromApiKey
792        | ApiKeyErrorCode::InvalidReferenceIdFromApiKey
793        | ApiKeyErrorCode::NoDefaultConfiguration
794        | ApiKeyErrorCode::OrganizationIdRequired
795        | ApiKeyErrorCode::ServerOnlyProperty
796        | ApiKeyErrorCode::FailedToUpdateApiKey
797        | ApiKeyErrorCode::InvalidMetadataType => 400,
798    };
799    AuthError::Upstream {
800        status,
801        code: code.as_str(),
802        message: code.message(),
803    }
804}
805
806fn admit_expired_cleanup(bypass: bool) -> bool {
807    let mut last = LAST_EXPIRED_CHECK
808        .lock()
809        .unwrap_or_else(std::sync::PoisonError::into_inner);
810    let now = chrono::Utc::now().timestamp_millis();
811    if !bypass && last.is_some_and(|previous| now.saturating_sub(previous) < 10_000) {
812        return false;
813    }
814    *last = Some(now);
815    true
816}
817
818/// Keys written before `config_id` existed carry no value, so absent and
819/// `"default"` denote the same configuration.
820pub(super) fn is_default_config_id(config_id: &str) -> bool {
821    config_id.is_empty() || config_id == "default"
822}
823
824/// Whether a stored key belongs to the addressed configuration.
825pub(super) fn config_id_matches(key_config_id: &str, expected: &str) -> bool {
826    if is_default_config_id(key_config_id) && is_default_config_id(expected) {
827        return true;
828    }
829    key_config_id == expected
830}
831
832// LCOV_EXCL_START
833#[cfg(test)]
834mod tests {
835
836    use super::*;
837    use alibi_core::wire::{SessionView, UserView};
838    use alibi_core::{
839        AuthContext, AuthPlugin, CreateSession, CreateUser, HttpMethod, UpdateApiKey,
840    };
841    use chrono::{Duration, Utc};
842    use std::collections::HashMap;
843    use std::sync::Arc;
844
845    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
846
847    async fn create_test_context_with_user() -> (AuthContext<TestSchema>, UserView, SessionView) {
848        let config = Arc::new(alibi_core::AuthConfig::new(
849            "test-secret-key-at-least-32-chars-long",
850        ));
851        let database = crate::plugins::test_helpers::create_test_database().await;
852        let ctx = AuthContext::new(config, Arc::clone(&database));
853
854        let user = database
855            .create_user(
856                CreateUser::new()
857                    .with_email("test@example.com")
858                    .with_name("Test User"),
859            )
860            .await
861            .unwrap();
862        let wire_user = UserView::from(&user);
863
864        let session = database
865            .create_session(CreateSession {
866                additional_fields: alibi_core::field_policy::FieldValues::default(),
867                token: None,
868                active_team_id: None,
869                user_id: user.id().to_string(),
870                expires_at: Utc::now() + Duration::hours(24),
871                ip_address: Some("127.0.0.1".to_owned()),
872                user_agent: Some("test-agent".to_owned()),
873                impersonated_by: None,
874                active_organization_id: None,
875            })
876            .await
877            .unwrap();
878        let wire_session = SessionView::from(&session);
879
880        (ctx, wire_user, wire_session)
881    }
882
883    async fn create_user_with_session(
884        ctx: &AuthContext<impl alibi_core::AuthSchema>,
885        email: &str,
886    ) -> (UserView, SessionView) {
887        let user = ctx
888            .database
889            .create_user(
890                CreateUser::new()
891                    .with_email(email.to_owned())
892                    .with_name("Another User"),
893            )
894            .await
895            .unwrap();
896        let wire_user = UserView::from(&user);
897
898        let session = ctx
899            .database
900            .create_session(CreateSession {
901                additional_fields: alibi_core::field_policy::FieldValues::default(),
902                token: None,
903                active_team_id: None,
904                user_id: user.id().to_string(),
905                expires_at: Utc::now() + Duration::hours(24),
906                ip_address: None,
907                user_agent: None,
908                impersonated_by: None,
909                active_organization_id: None,
910            })
911            .await
912            .unwrap();
913        let wire_session = SessionView::from(&session);
914
915        (wire_user, wire_session)
916    }
917
918    fn create_auth_request(
919        method: HttpMethod,
920        path: &str,
921        token: Option<&str>,
922        body: Option<serde_json::Value>,
923        query: Option<HashMap<String, String>>,
924    ) -> AuthRequest {
925        let mut headers = HashMap::new();
926        if let Some(token) = token {
927            headers.insert(
928                "cookie".to_owned(),
929                format!(
930                    "better-auth.session_token={}",
931                    alibi_core::utils::cookie_utils::sign_cookie_value(
932                        token,
933                        &crate::plugins::test_helpers::create_test_config().secret
934                    )
935                ),
936            );
937        }
938
939        AuthRequest::from_parts(
940            method,
941            path.to_owned(),
942            headers,
943            body.map(|b| serde_json::to_vec(&b).unwrap()),
944            query.unwrap_or_default(),
945        )
946    }
947
948    fn json_body(response: &AuthResponse) -> serde_json::Value {
949        serde_json::from_slice(&response.body).unwrap()
950    }
951
952    /// Test helper: verify a key and return the same JSON shape the old HTTP
953    /// handler produced. Calls the public server-only verification method.
954    async fn verify_key(
955        plugin: &ApiKeyPlugin,
956        ctx: &AuthContext<impl alibi_core::AuthSchema>,
957        raw_key: &str,
958        permissions: Option<&serde_json::Value>,
959    ) -> serde_json::Value {
960        match plugin
961            .verify_api_key(
962                &VerifyApiKey {
963                    key: raw_key,
964                    config_id: None,
965                    permissions,
966                },
967                ctx,
968            )
969            .await
970        {
971            Ok(view) => serde_json::json!({ "valid": true, "error": null, "key": view }),
972            Err(ApiKeyVerificationError::Validation(error)) => serde_json::json!({
973                "valid": false,
974                "error": error,
975                "key": null,
976            }),
977            Err(
978                ApiKeyVerificationError::Internal(error)
979                | ApiKeyVerificationError::ExplicitValidator(error),
980            ) => panic!("Verification failed: {error}"),
981        }
982    }
983
984    /// Create a key via the HTTP handler (client-allowed fields only), then
985    /// patch server-only fields directly through the database.
986    ///
987    /// `server_fields` may contain: remaining, `refill_interval`, `refill_amount`,
988    /// `rate_limit_enabled`, `rate_limit_time_window`, `rate_limit_max`, permissions.
989    async fn create_key_with_server_fields(
990        plugin: &ApiKeyPlugin,
991        ctx: &AuthContext<impl alibi_core::AuthSchema>,
992        token: &str,
993        client_body: serde_json::Value,
994        server_fields: UpdateApiKey,
995    ) -> (String, String) {
996        let (id, raw_key) = create_key_and_get_raw(plugin, ctx, token, client_body).await;
997        ctx.database
998            .update_api_key(&id, server_fields)
999            .await
1000            .unwrap();
1001        (id, raw_key)
1002    }
1003
1004    /// Test helper: delete all expired keys and return a success JSON.
1005    async fn delete_all_expired(
1006        ctx: &AuthContext<impl alibi_core::AuthSchema>,
1007    ) -> serde_json::Value {
1008        let _ignored_result = ctx.database.delete_expired_api_keys().await.unwrap();
1009        serde_json::json!({ "success": true, "error": null })
1010    }
1011
1012    async fn create_key_and_get_id(
1013        plugin: &ApiKeyPlugin,
1014        ctx: &AuthContext<impl alibi_core::AuthSchema>,
1015        token: &str,
1016        name: &str,
1017    ) -> String {
1018        let req = create_auth_request(
1019            HttpMethod::Post,
1020            "/api-key/create",
1021            Some(token),
1022            Some(serde_json::json!({ "name": name })),
1023            None,
1024        );
1025        let response = plugin.handle_create(&req, ctx).await.unwrap();
1026        assert_eq!(response.status, 200);
1027        (*(json_body(&response))
1028            .get("id")
1029            .unwrap_or(&serde_json::Value::Null))
1030        .as_str()
1031        .unwrap()
1032        .to_owned()
1033    }
1034
1035    /// Helper: create a key and return (id, `raw_key`)
1036    async fn create_key_and_get_raw(
1037        plugin: &ApiKeyPlugin,
1038        ctx: &AuthContext<impl alibi_core::AuthSchema>,
1039        token: &str,
1040        body: serde_json::Value,
1041    ) -> (String, String) {
1042        let req = create_auth_request(
1043            HttpMethod::Post,
1044            "/api-key/create",
1045            Some(token),
1046            Some(body),
1047            None,
1048        );
1049        let response = plugin.handle_create(&req, ctx).await.unwrap();
1050        assert_eq!(response.status, 200);
1051        let b = json_body(&response);
1052        (
1053            (*(b).get("id").expect("fixture contains the requested index"))
1054                .as_str()
1055                .unwrap()
1056                .to_owned(),
1057            (*(b)
1058                .get("key")
1059                .expect("fixture contains the requested index"))
1060            .as_str()
1061            .unwrap()
1062            .to_owned(),
1063        )
1064    }
1065
1066    // -----------------------------------------------------------------------
1067    // Existing tests (kept)
1068    // -----------------------------------------------------------------------
1069
1070    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1071    #[tokio::test]
1072    async fn test_create_and_get_do_not_expose_hash() {
1073        let plugin = ApiKeyPlugin::builder().prefix("ba_".to_owned()).build();
1074        let (ctx, _user, session) = create_test_context_with_user().await;
1075
1076        let create_req = create_auth_request(
1077            HttpMethod::Post,
1078            "/api-key/create",
1079            Some(&session.token),
1080            Some(serde_json::json!({ "name": "primary" })),
1081            None,
1082        );
1083        let create_response = plugin.handle_create(&create_req, &ctx).await.unwrap();
1084        assert_eq!(create_response.status, 200);
1085
1086        let body = json_body(&create_response);
1087        assert!(body.get("key").is_some());
1088        assert!(body.get("key_hash").is_none());
1089        assert!(body.get("hash").is_none());
1090
1091        let id = (*(body).get("id").unwrap_or(&serde_json::Value::Null))
1092            .as_str()
1093            .unwrap();
1094        let mut query = HashMap::new();
1095        query.insert("id".to_owned(), id.to_owned());
1096
1097        let get_req = create_auth_request(
1098            HttpMethod::Get,
1099            "/api-key/get",
1100            Some(&session.token),
1101            None,
1102            Some(query),
1103        );
1104        let get_response = plugin.handle_get(&get_req, &ctx).await.unwrap();
1105        assert_eq!(get_response.status, 200);
1106
1107        let get_body = json_body(&get_response);
1108        assert!(get_body.get("key").is_none());
1109        assert!(get_body.get("key_hash").is_none());
1110    }
1111
1112    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1113    #[tokio::test]
1114    async fn test_create_rejects_invalid_expires_in() {
1115        let plugin = ApiKeyPlugin::builder().build();
1116        let (ctx, _user, session) = create_test_context_with_user().await;
1117
1118        let req = create_auth_request(
1119            HttpMethod::Post,
1120            "/api-key/create",
1121            Some(&session.token),
1122            Some(serde_json::json!({ "expiresIn": -1 })),
1123            None,
1124        );
1125        let response = plugin.handle_create(&req, &ctx).await;
1126        // Should be rejected due to validation (negative expires_in)
1127        assert!(response.is_err() || response.unwrap().status != 200);
1128    }
1129
1130    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1131    #[tokio::test]
1132    async fn test_get_update_delete_return_404_for_non_owner() {
1133        let plugin = ApiKeyPlugin::builder().build();
1134        let (ctx, _user1, session1) = create_test_context_with_user().await;
1135        let (_user2, session2) = create_user_with_session(&ctx, "other@example.com").await;
1136        let key_id = create_key_and_get_id(&plugin, &ctx, &session1.token, "owner-key").await;
1137
1138        let mut get_query = HashMap::new();
1139        get_query.insert("id".to_owned(), key_id.clone());
1140        let get_req = create_auth_request(
1141            HttpMethod::Get,
1142            "/api-key/get",
1143            Some(&session2.token),
1144            None,
1145            Some(get_query),
1146        );
1147        let get_err = plugin.handle_get(&get_req, &ctx).await.unwrap_err();
1148        assert_eq!(get_err.status_code(), 404);
1149
1150        let update_req = create_auth_request(
1151            HttpMethod::Post,
1152            "/api-key/update",
1153            Some(&session2.token),
1154            Some(serde_json::json!({ "keyId": key_id, "name": "new-name" })),
1155            None,
1156        );
1157        let update_err = plugin.handle_update(&update_req, &ctx).await.unwrap_err();
1158        assert_eq!(update_err.status_code(), 404);
1159
1160        let delete_req = create_auth_request(
1161            HttpMethod::Post,
1162            "/api-key/delete",
1163            Some(&session2.token),
1164            Some(serde_json::json!({ "keyId": key_id })),
1165            None,
1166        );
1167        let delete_err = plugin.handle_delete(&delete_req, &ctx).await.unwrap_err();
1168        assert_eq!(delete_err.status_code(), 404);
1169    }
1170
1171    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1172    #[tokio::test]
1173    async fn test_list_returns_only_user_keys() {
1174        let plugin = ApiKeyPlugin::builder().build();
1175        let (ctx, user1, session1) = create_test_context_with_user().await;
1176        let (_user2, session2) = create_user_with_session(&ctx, "other@example.com").await;
1177
1178        drop(create_key_and_get_id(&plugin, &ctx, &session1.token, "u1-key").await);
1179        drop(create_key_and_get_id(&plugin, &ctx, &session2.token, "u2-key").await);
1180
1181        let list_req = create_auth_request(
1182            HttpMethod::Get,
1183            "/api-key/list",
1184            Some(&session1.token),
1185            None,
1186            None,
1187        );
1188        let list_response = plugin.handle_list(&list_req, &ctx).await.unwrap();
1189        assert_eq!(list_response.status, 200);
1190
1191        let list_body = json_body(&list_response);
1192        // Upstream returns a paginated envelope rather than a bare array.
1193        let list = (*(list_body)
1194            .get("apiKeys")
1195            .unwrap_or(&serde_json::Value::Null))
1196        .as_array()
1197        .unwrap();
1198        assert_eq!(list.len(), 1);
1199        assert_eq!(
1200            (*(list_body).get("total").unwrap_or(&serde_json::Value::Null)),
1201            1
1202        );
1203        // Upstream renamed the owner field to `referenceId` (a user or an
1204        // organization id) and stamps the owning configuration on every key.
1205        assert_eq!(
1206            (*(*(list)
1207                .first()
1208                .expect("fixture contains the requested index"))
1209            .get("referenceId")
1210            .expect("fixture contains the requested index"))
1211            .as_str()
1212            .unwrap(),
1213            user1.id
1214        );
1215        assert_eq!(
1216            (*(*(list)
1217                .first()
1218                .expect("fixture contains the requested index"))
1219            .get("configId")
1220            .expect("fixture contains the requested index"))
1221            .as_str()
1222            .unwrap(),
1223            "default"
1224        );
1225        assert!(
1226            (*(list)
1227                .first()
1228                .expect("fixture contains the requested index"))
1229            .get("userId")
1230            .is_none()
1231        );
1232        assert!(
1233            (*(list)
1234                .first()
1235                .expect("fixture contains the requested index"))
1236            .get("key")
1237            .is_none()
1238        );
1239        assert!(
1240            (*(list)
1241                .first()
1242                .expect("fixture contains the requested index"))
1243            .get("key_hash")
1244            .is_none()
1245        );
1246    }
1247
1248    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1249    #[tokio::test]
1250    async fn test_owner_can_delete_key() {
1251        let plugin = ApiKeyPlugin::builder().build();
1252        let (ctx, _user, session) = create_test_context_with_user().await;
1253        let key_id = create_key_and_get_id(&plugin, &ctx, &session.token, "to-delete").await;
1254
1255        let delete_req = create_auth_request(
1256            HttpMethod::Post,
1257            "/api-key/delete",
1258            Some(&session.token),
1259            Some(serde_json::json!({ "keyId": key_id })),
1260            None,
1261        );
1262        let delete_response = plugin.handle_delete(&delete_req, &ctx).await.unwrap();
1263        assert_eq!(delete_response.status, 200);
1264
1265        let deleted = ctx.database.get_api_key_by_id(&key_id).await.unwrap();
1266        assert!(deleted.is_none());
1267    }
1268
1269    // -----------------------------------------------------------------------
1270    // New tests: verify, rate-limit, remaining/refill, delete expired, config
1271    // -----------------------------------------------------------------------
1272
1273    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1274    #[tokio::test]
1275    async fn test_verify_valid_key() {
1276        let plugin = ApiKeyPlugin::builder().build();
1277        let (ctx, _user, session) = create_test_context_with_user().await;
1278
1279        let (_id, raw_key) = create_key_and_get_raw(
1280            &plugin,
1281            &ctx,
1282            &session.token,
1283            serde_json::json!({ "name": "verify-test" }),
1284        )
1285        .await;
1286
1287        let body = verify_key(&plugin, &ctx, &raw_key, None).await;
1288        assert_eq!(
1289            (*(body).get("valid").unwrap_or(&serde_json::Value::Null)),
1290            true
1291        );
1292        assert!((*(body).get("key").unwrap_or(&serde_json::Value::Null)).is_object());
1293    }
1294
1295    #[tokio::test]
1296    async fn test_verify_invalid_key() {
1297        let plugin = ApiKeyPlugin::builder().build();
1298        let (ctx, _user, _session) = create_test_context_with_user().await;
1299
1300        let body = verify_key(&plugin, &ctx, "definitely-not-a-valid-key", None).await;
1301        assert_eq!(
1302            (*(body).get("valid").unwrap_or(&serde_json::Value::Null)),
1303            false
1304        );
1305        assert!((*(body).get("error").unwrap_or(&serde_json::Value::Null)).is_object());
1306    }
1307
1308    #[tokio::test]
1309    async fn test_verify_disabled_key() {
1310        let plugin = ApiKeyPlugin::builder().build();
1311        let (ctx, _user, session) = create_test_context_with_user().await;
1312
1313        let (id, raw_key) = create_key_and_get_raw(
1314            &plugin,
1315            &ctx,
1316            &session.token,
1317            serde_json::json!({ "name": "disable-test" }),
1318        )
1319        .await;
1320
1321        let update = UpdateApiKey {
1322            enabled: Some(false),
1323            ..Default::default()
1324        };
1325        ctx.database.update_api_key(&id, update).await.unwrap();
1326
1327        let body = verify_key(&plugin, &ctx, &raw_key, None).await;
1328        assert_eq!(
1329            (*(body).get("valid").unwrap_or(&serde_json::Value::Null)),
1330            false
1331        );
1332        assert_eq!(
1333            (*(*(body).get("error").unwrap_or(&serde_json::Value::Null))
1334                .get("code")
1335                .unwrap_or(&serde_json::Value::Null)),
1336            "KEY_DISABLED"
1337        );
1338    }
1339
1340    #[tokio::test]
1341    async fn test_verify_expired_key() {
1342        let plugin = ApiKeyPlugin::builder().build();
1343        let (ctx, _user, session) = create_test_context_with_user().await;
1344
1345        let (id, raw_key) = create_key_and_get_raw(
1346            &plugin,
1347            &ctx,
1348            &session.token,
1349            serde_json::json!({ "name": "expire-test" }),
1350        )
1351        .await;
1352
1353        let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
1354        let update = UpdateApiKey {
1355            expires_at: Some(Some(past)),
1356            ..Default::default()
1357        };
1358        ctx.database.update_api_key(&id, update).await.unwrap();
1359
1360        let body = verify_key(&plugin, &ctx, &raw_key, None).await;
1361        assert_eq!(
1362            (*(body).get("valid").unwrap_or(&serde_json::Value::Null)),
1363            false
1364        );
1365        assert_eq!(
1366            (*(*(body).get("error").unwrap_or(&serde_json::Value::Null))
1367                .get("code")
1368                .unwrap_or(&serde_json::Value::Null)),
1369            "KEY_EXPIRED"
1370        );
1371
1372        // The key should have been deleted
1373        let deleted = ctx.database.get_api_key_by_id(&id).await.unwrap();
1374        assert!(deleted.is_none());
1375    }
1376
1377    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1378    #[tokio::test]
1379    async fn test_verify_remaining_consumption() {
1380        let plugin = ApiKeyPlugin::builder().build();
1381        let (ctx, _user, session) = create_test_context_with_user().await;
1382
1383        let (_id, raw_key) = create_key_with_server_fields(
1384            &plugin,
1385            &ctx,
1386            &session.token,
1387            serde_json::json!({ "name": "remain-test" }),
1388            UpdateApiKey {
1389                remaining: Some(2.0),
1390                ..Default::default()
1391            },
1392        )
1393        .await;
1394
1395        // First verify - remaining goes from 2 to 1
1396        let r1 = verify_key(&plugin, &ctx, &raw_key, None).await;
1397        assert_eq!(
1398            (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1399            true
1400        );
1401        assert_eq!(
1402            (*(*(r1).get("key").unwrap_or(&serde_json::Value::Null))
1403                .get("remaining")
1404                .unwrap_or(&serde_json::Value::Null)),
1405            1
1406        );
1407
1408        // Second verify - remaining goes from 1 to 0
1409        let r2 = verify_key(&plugin, &ctx, &raw_key, None).await;
1410        assert_eq!(
1411            (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
1412            true
1413        );
1414        assert_eq!(
1415            (*(*(r2).get("key").unwrap_or(&serde_json::Value::Null))
1416                .get("remaining")
1417                .unwrap_or(&serde_json::Value::Null)),
1418            0
1419        );
1420
1421        // Third verify - should fail (usage exceeded)
1422        let r3 = verify_key(&plugin, &ctx, &raw_key, None).await;
1423        assert_eq!(
1424            (*(r3).get("valid").unwrap_or(&serde_json::Value::Null)),
1425            false
1426        );
1427        assert_eq!(
1428            (*(*(r3).get("error").unwrap_or(&serde_json::Value::Null))
1429                .get("code")
1430                .unwrap_or(&serde_json::Value::Null)),
1431            "USAGE_EXCEEDED"
1432        );
1433    }
1434
1435    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1436    #[tokio::test]
1437    async fn test_verify_rate_limiting() {
1438        let plugin = ApiKeyPlugin::builder()
1439            .rate_limit(RateLimitDefaults {
1440                enabled: true,
1441                time_window: 60_000.0,
1442                max_requests: 2.0,
1443            })
1444            .build();
1445        let (ctx, _user, session) = create_test_context_with_user().await;
1446
1447        let (_id, raw_key) = create_key_with_server_fields(
1448            &plugin,
1449            &ctx,
1450            &session.token,
1451            serde_json::json!({ "name": "rl-test" }),
1452            UpdateApiKey {
1453                rate_limit_enabled: Some(true),
1454                rate_limit_time_window: Some(60_000.0),
1455                rate_limit_max: Some(2.0),
1456                ..Default::default()
1457            },
1458        )
1459        .await;
1460
1461        // First two should succeed
1462        let r1 = verify_key(&plugin, &ctx, &raw_key, None).await;
1463        assert_eq!(
1464            (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1465            true
1466        );
1467
1468        let r2 = verify_key(&plugin, &ctx, &raw_key, None).await;
1469        assert_eq!(
1470            (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
1471            true
1472        );
1473
1474        // Third should fail with rate limit
1475        let r3 = verify_key(&plugin, &ctx, &raw_key, None).await;
1476        assert_eq!(
1477            (*(r3).get("valid").unwrap_or(&serde_json::Value::Null)),
1478            false
1479        );
1480        assert_eq!(
1481            (*(*(r3).get("error").unwrap_or(&serde_json::Value::Null))
1482                .get("code")
1483                .unwrap_or(&serde_json::Value::Null)),
1484            "RATE_LIMITED"
1485        );
1486    }
1487
1488    #[tokio::test]
1489    async fn test_delete_all_expired() {
1490        let plugin = ApiKeyPlugin::builder().build();
1491        let (ctx, fixture_user, session) = create_test_context_with_user().await;
1492
1493        // Create two keys
1494        let (id1, _) = create_key_and_get_raw(
1495            &plugin,
1496            &ctx,
1497            &session.token,
1498            serde_json::json!({ "name": "will-expire" }),
1499        )
1500        .await;
1501        let (_id2, _) = create_key_and_get_raw(
1502            &plugin,
1503            &ctx,
1504            &session.token,
1505            serde_json::json!({ "name": "wont-expire" }),
1506        )
1507        .await;
1508
1509        // Expire the first key
1510        let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
1511        ctx.database
1512            .update_api_key(
1513                &id1,
1514                UpdateApiKey {
1515                    expires_at: Some(Some(past)),
1516                    ..Default::default()
1517                },
1518            )
1519            .await
1520            .unwrap();
1521
1522        let body = delete_all_expired(&ctx).await;
1523        assert_eq!(
1524            (*(body).get("success").unwrap_or(&serde_json::Value::Null)),
1525            true
1526        );
1527
1528        // Only the non-expired key should remain
1529        let remaining_keys = ctx
1530            .database
1531            .list_api_keys_by_reference(&fixture_user.id)
1532            .await
1533            .unwrap();
1534        assert_eq!(remaining_keys.len(), 1);
1535    }
1536
1537    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1538    #[tokio::test]
1539    async fn test_verify_permissions() {
1540        let plugin = ApiKeyPlugin::builder().build();
1541        let (ctx, _user, session) = create_test_context_with_user().await;
1542
1543        let (_id, raw_key) = create_key_with_server_fields(
1544            &plugin,
1545            &ctx,
1546            &session.token,
1547            serde_json::json!({ "name": "perm-test" }),
1548            UpdateApiKey {
1549                permissions: Some(
1550                    serde_json::to_string(&serde_json::json!({
1551                        "admin": ["read", "write"],
1552                        "user": ["read"]
1553                    }))
1554                    .unwrap(),
1555                ),
1556                ..Default::default()
1557            },
1558        )
1559        .await;
1560
1561        // Verify with matching permissions -> should pass
1562        let perms_ok = serde_json::json!({ "admin": ["read"] });
1563        let r1 = verify_key(&plugin, &ctx, &raw_key, Some(&perms_ok)).await;
1564        assert_eq!(
1565            (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1566            true
1567        );
1568
1569        // Verify with non-matching permissions -> should fail
1570        let perms_fail = serde_json::json!({ "superadmin": ["delete"] });
1571        let r2 = verify_key(&plugin, &ctx, &raw_key, Some(&perms_fail)).await;
1572        assert_eq!(
1573            (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
1574            false
1575        );
1576    }
1577
1578    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1579    #[tokio::test]
1580    async fn test_config_validation_prefix_length() {
1581        let plugin = ApiKeyPlugin::builder()
1582            .min_prefix_length(2)
1583            .max_prefix_length(5)
1584            .build();
1585        let (ctx, _user, session) = create_test_context_with_user().await;
1586
1587        // Too short prefix
1588        let req = create_auth_request(
1589            HttpMethod::Post,
1590            "/api-key/create",
1591            Some(&session.token),
1592            Some(serde_json::json!({ "name": "test", "prefix": "a" })),
1593            None,
1594        );
1595        let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
1596        assert!(err.to_string().contains("prefix length"));
1597
1598        // Too long prefix
1599        let req2 = create_auth_request(
1600            HttpMethod::Post,
1601            "/api-key/create",
1602            Some(&session.token),
1603            Some(serde_json::json!({ "name": "test", "prefix": "toolong" })),
1604            None,
1605        );
1606        let err2 = plugin.handle_create(&req2, &ctx).await.unwrap_err();
1607        assert!(err2.to_string().contains("prefix length"));
1608    }
1609
1610    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1611    #[tokio::test]
1612    async fn test_config_require_name() {
1613        let plugin = ApiKeyPlugin::builder().require_name(true).build();
1614        let (ctx, _user, session) = create_test_context_with_user().await;
1615
1616        // No name provided -> should fail
1617        let req = create_auth_request(
1618            HttpMethod::Post,
1619            "/api-key/create",
1620            Some(&session.token),
1621            Some(serde_json::json!({})),
1622            None,
1623        );
1624        let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
1625        assert!(err.to_string().contains("name is required"));
1626    }
1627
1628    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1629    #[tokio::test]
1630    async fn test_config_metadata_disabled() {
1631        let plugin = ApiKeyPlugin::builder().build(); // enable_metadata defaults to false
1632        let (ctx, _user, session) = create_test_context_with_user().await;
1633
1634        let req = create_auth_request(
1635            HttpMethod::Post,
1636            "/api-key/create",
1637            Some(&session.token),
1638            Some(serde_json::json!({ "name": "test", "metadata": { "env": "prod" } })),
1639            None,
1640        );
1641        let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
1642        assert!(err.to_string().contains("Metadata is disabled"));
1643    }
1644
1645    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1646    #[tokio::test]
1647    async fn test_config_metadata_enabled() {
1648        let plugin = ApiKeyPlugin::builder().enable_metadata(true).build();
1649        let (ctx, _user, session) = create_test_context_with_user().await;
1650
1651        let req = create_auth_request(
1652            HttpMethod::Post,
1653            "/api-key/create",
1654            Some(&session.token),
1655            Some(serde_json::json!({ "name": "test", "metadata": { "env": "prod" } })),
1656            None,
1657        );
1658        let resp = plugin.handle_create(&req, &ctx).await.unwrap();
1659        assert_eq!(resp.status, 200);
1660        let body = json_body(&resp);
1661        assert_eq!(
1662            (*(*(body).get("metadata").unwrap_or(&serde_json::Value::Null))
1663                .get("env")
1664                .unwrap_or(&serde_json::Value::Null)),
1665            "prod"
1666        );
1667    }
1668
1669    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1670    #[tokio::test]
1671    async fn test_update_with_expires_in() {
1672        let plugin = ApiKeyPlugin::builder().build();
1673        let (ctx, _user, session) = create_test_context_with_user().await;
1674        let key_id = create_key_and_get_id(&plugin, &ctx, &session.token, "update-exp").await;
1675
1676        let update_req = create_auth_request(
1677            HttpMethod::Post,
1678            "/api-key/update",
1679            Some(&session.token),
1680            Some(serde_json::json!({
1681                "keyId": key_id,
1682                "expiresIn": 86400
1683            })),
1684            None,
1685        );
1686        let resp = plugin.handle_update(&update_req, &ctx).await.unwrap();
1687        assert_eq!(resp.status, 200);
1688        let body = json_body(&resp);
1689        assert!((*(body).get("expiresAt").unwrap_or(&serde_json::Value::Null)).is_string());
1690    }
1691
1692    // Note: /api-key/verify and /api-key/delete-all-expired-api-keys are
1693    // server-only in TS (no HTTP routes), so the Rust plugin does not expose
1694    // them either. Route dispatch tests for these have been removed.
1695
1696    // Ensure refillInterval + refillAmount require each other (validation logic)
1697    #[tokio::test]
1698    async fn test_refill_logic() {
1699        let result = ApiKeyPlugin::validate_refill(Some(60_000.0), None);
1700        assert!(result.is_err());
1701        assert!(result.unwrap_err().to_string().contains("refillAmount"));
1702
1703        let result_2 = ApiKeyPlugin::validate_refill(None, Some(10.0));
1704        assert!(result_2.is_err());
1705
1706        let result_3 = ApiKeyPlugin::validate_refill(Some(60_000.0), Some(10.0));
1707        assert!(result_3.is_ok());
1708    }
1709
1710    mod session_tests {
1711        use super::*;
1712
1713        // =======================================================================
1714        // Comprehensive integration tests (9 scenarios from the test plan)
1715        // =======================================================================
1716
1717        #[tokio::test]
1718        async fn test_virtual_session_answers_get_and_post_get_session() {
1719            use alibi_core::AuthPlugin;
1720
1721            let plugin = ApiKeyPlugin::builder()
1722                .enable_session_for_api_keys(true)
1723                .build();
1724            let (ctx, user, session) = create_test_context_with_user().await;
1725            let (id, raw_key) = create_key_and_get_raw(
1726                &plugin,
1727                &ctx,
1728                &session.token,
1729                serde_json::json!({ "name": "get-session" }),
1730            )
1731            .await;
1732
1733            for method in [HttpMethod::Get, HttpMethod::Post] {
1734                let mut request = create_auth_request(method, "/get-session", None, None, None);
1735                drop(
1736                    request
1737                        .headers
1738                        .insert("x-api-key".to_owned(), raw_key.clone()),
1739                );
1740                let action = AuthPlugin::<TestSchema>::before_request(&plugin, &request, &ctx)
1741                    .await
1742                    .unwrap();
1743                let Some(BeforeRequestAction::Respond(response)) = action else {
1744                    panic!("API key sessions must answer GET and POST before route dispatch")
1745                };
1746                assert_eq!(response.status, 200);
1747                let body = json_body(&response);
1748                assert_eq!(
1749                    (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
1750                        .get("id")
1751                        .unwrap_or(&serde_json::Value::Null)),
1752                    id
1753                );
1754                assert_eq!(
1755                    (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
1756                        .get("token")
1757                        .unwrap_or(&serde_json::Value::Null)),
1758                    raw_key
1759                );
1760                assert_eq!(
1761                    (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
1762                        .get("userId")
1763                        .unwrap_or(&serde_json::Value::Null)),
1764                    user.id
1765                );
1766            }
1767        }
1768
1769        // 1. Virtual session: before_request injects session without DB writes
1770        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1771        #[tokio::test]
1772        async fn test_virtual_session_creates_no_db_session() {
1773            let plugin = ApiKeyPlugin::builder()
1774                .enable_session_for_api_keys(true)
1775                .build();
1776            let (ctx, fixture_user, session) = create_test_context_with_user().await;
1777
1778            // Create an API key
1779            let (_id, raw_key) = create_key_and_get_raw(
1780                &plugin,
1781                &ctx,
1782                &session.token,
1783                serde_json::json!({ "name": "virtual-session-test" }),
1784            )
1785            .await;
1786
1787            // Count sessions before
1788            let sessions_before = ctx
1789                .database
1790                .get_user_sessions(&fixture_user.id)
1791                .await
1792                .unwrap()
1793                .len();
1794
1795            // Simulate a request to a protected route with only x-api-key header
1796            let mut headers = HashMap::new();
1797            headers.insert("x-api-key".to_owned(), raw_key.clone());
1798            let req = AuthRequest::from_parts(
1799                HttpMethod::Post,
1800                "/update-user".to_owned(),
1801                headers,
1802                None,
1803                HashMap::new(),
1804            );
1805
1806            // Call before_request -- should return InjectSession
1807            let action = plugin.before_request(&req, &ctx).await.unwrap();
1808            assert!(action.is_some(), "before_request should return an action");
1809            match action.unwrap() {
1810                BeforeRequestAction::InjectSession { session: session_2 } => {
1811                    assert_eq!(session_2.user_id, fixture_user.id);
1812                }
1813                BeforeRequestAction::Respond(_) | BeforeRequestAction::ReplaceHeaders { .. } => {
1814                    panic!("Expected InjectSession, got Respond");
1815                }
1816            }
1817
1818            // Count sessions after -- should be unchanged (no DB writes)
1819            let sessions_after = ctx
1820                .database
1821                .get_user_sessions(&fixture_user.id)
1822                .await
1823                .unwrap()
1824                .len();
1825            assert_eq!(
1826                sessions_before, sessions_after,
1827                "No new sessions should be created in the database"
1828            );
1829        }
1830
1831        // 2. Virtual session on /get-session: synthetic response
1832        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1833        #[tokio::test]
1834        async fn test_virtual_session_on_get_session() {
1835            let plugin = ApiKeyPlugin::builder()
1836                .enable_session_for_api_keys(true)
1837                .build();
1838            let (ctx, user, session) = create_test_context_with_user().await;
1839
1840            let (_id, raw_key) = create_key_and_get_raw(
1841                &plugin,
1842                &ctx,
1843                &session.token,
1844                serde_json::json!({ "name": "get-session-test" }),
1845            )
1846            .await;
1847
1848            // Send request to /get-session with x-api-key header
1849            let mut headers = HashMap::new();
1850            headers.insert("x-api-key".to_owned(), raw_key.clone());
1851            let req = AuthRequest::from_parts(
1852                HttpMethod::Get,
1853                "/get-session".to_owned(),
1854                headers,
1855                None,
1856                HashMap::new(),
1857            );
1858
1859            let action = plugin.before_request(&req, &ctx).await.unwrap();
1860            assert!(action.is_some());
1861            match action.unwrap() {
1862                BeforeRequestAction::Respond(resp) => {
1863                    assert_eq!(resp.status, 200);
1864                    let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
1865                    // Should contain user data
1866                    assert_eq!(
1867                        (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
1868                            .get("id")
1869                            .unwrap_or(&serde_json::Value::Null)),
1870                        user.id
1871                    );
1872                    assert_eq!(
1873                        (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
1874                            .get("email")
1875                            .unwrap_or(&serde_json::Value::Null)),
1876                        "test@example.com"
1877                    );
1878                    // Should contain session-like data
1879                    assert!(
1880                        (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
1881                            .get("id")
1882                            .unwrap_or(&serde_json::Value::Null))
1883                        .is_string()
1884                    );
1885                    assert_eq!(
1886                        (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
1887                            .get("userId")
1888                            .unwrap_or(&serde_json::Value::Null)),
1889                        user.id
1890                    );
1891                }
1892                BeforeRequestAction::InjectSession { .. }
1893                | BeforeRequestAction::ReplaceHeaders { .. } => {
1894                    panic!("Expected Respond for /get-session, got InjectSession");
1895                }
1896            }
1897        }
1898
1899        // 3. Rate limiting: create key with rateLimitMax=2, 3rd call fails
1900        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1901        #[tokio::test]
1902        async fn test_rate_limiting_third_call_fails() {
1903            let plugin = ApiKeyPlugin::builder()
1904                .rate_limit(RateLimitDefaults {
1905                    enabled: true,
1906                    time_window: 60_000.0,
1907                    max_requests: 2.0,
1908                })
1909                .build();
1910            let (ctx, _user, session) = create_test_context_with_user().await;
1911
1912            let (_id, raw_key) = create_key_with_server_fields(
1913                &plugin,
1914                &ctx,
1915                &session.token,
1916                serde_json::json!({ "name": "rl-integration" }),
1917                UpdateApiKey {
1918                    rate_limit_enabled: Some(true),
1919                    rate_limit_time_window: Some(60_000.0),
1920                    rate_limit_max: Some(2.0),
1921                    ..Default::default()
1922                },
1923            )
1924            .await;
1925
1926            // First two pass
1927            let r1 = verify_key(&plugin, &ctx, &raw_key, None).await;
1928            assert_eq!(
1929                (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1930                true,
1931                "1st request should pass"
1932            );
1933
1934            let r2 = verify_key(&plugin, &ctx, &raw_key, None).await;
1935            assert_eq!(
1936                (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
1937                true,
1938                "2nd request should pass"
1939            );
1940
1941            // Third should fail
1942            let r3 = verify_key(&plugin, &ctx, &raw_key, None).await;
1943            assert_eq!(
1944                (*(r3).get("valid").unwrap_or(&serde_json::Value::Null)),
1945                false,
1946                "3rd request should be rate-limited"
1947            );
1948            assert_eq!(
1949                (*(*(r3).get("error").unwrap_or(&serde_json::Value::Null))
1950                    .get("code")
1951                    .unwrap_or(&serde_json::Value::Null)),
1952                "RATE_LIMITED"
1953            );
1954        }
1955
1956        // 4. Remaining consumption: remaining=2, no refill, 3rd fails
1957        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1958        #[tokio::test]
1959        async fn test_remaining_consumption_no_refill() {
1960            let plugin = ApiKeyPlugin::builder().build();
1961            let (ctx, _user, session) = create_test_context_with_user().await;
1962
1963            let (_id, raw_key) = create_key_with_server_fields(
1964                &plugin,
1965                &ctx,
1966                &session.token,
1967                serde_json::json!({ "name": "remaining-test" }),
1968                UpdateApiKey {
1969                    remaining: Some(2.0),
1970                    ..Default::default()
1971                },
1972            )
1973            .await;
1974
1975            // 1st: remaining 2->1
1976            let r1 = verify_key(&plugin, &ctx, &raw_key, None).await;
1977            assert_eq!(
1978                (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1979                true
1980            );
1981            assert_eq!(
1982                (*(*(r1).get("key").unwrap_or(&serde_json::Value::Null))
1983                    .get("remaining")
1984                    .unwrap_or(&serde_json::Value::Null)),
1985                1
1986            );
1987
1988            // 2nd: remaining 1->0
1989            let r2 = verify_key(&plugin, &ctx, &raw_key, None).await;
1990            assert_eq!(
1991                (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
1992                true
1993            );
1994            assert_eq!(
1995                (*(*(r2).get("key").unwrap_or(&serde_json::Value::Null))
1996                    .get("remaining")
1997                    .unwrap_or(&serde_json::Value::Null)),
1998                0
1999            );
2000
2001            // 3rd: usage exceeded
2002            let r3 = verify_key(&plugin, &ctx, &raw_key, None).await;
2003            assert_eq!(
2004                (*(r3).get("valid").unwrap_or(&serde_json::Value::Null)),
2005                false
2006            );
2007            assert_eq!(
2008                (*(*(r3).get("error").unwrap_or(&serde_json::Value::Null))
2009                    .get("code")
2010                    .unwrap_or(&serde_json::Value::Null)),
2011                "USAGE_EXCEEDED"
2012            );
2013        }
2014
2015        // 5. Refill logic: remaining=1, refillInterval=100ms, refillAmount=10,
2016        //    verify once -> remaining=0, wait 150ms, verify -> refill to 10 then
2017        //    decrement to 9.
2018        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
2019        #[tokio::test]
2020        async fn test_refill_resets_remaining_after_interval() {
2021            let plugin = ApiKeyPlugin::builder().build();
2022            let (ctx, _user, session) = create_test_context_with_user().await;
2023
2024            // Use a very short refill interval for testing (100 ms)
2025            let (_id, raw_key) = create_key_with_server_fields(
2026                &plugin,
2027                &ctx,
2028                &session.token,
2029                serde_json::json!({ "name": "refill-test" }),
2030                UpdateApiKey {
2031                    remaining: Some(1.0),
2032                    refill_interval: Some(100.0),
2033                    refill_amount: Some(10.0),
2034                    ..Default::default()
2035                },
2036            )
2037            .await;
2038
2039            // First verify: remaining 1->0
2040            let r1 = verify_key(&plugin, &ctx, &raw_key, None).await;
2041            assert_eq!(
2042                (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
2043                true
2044            );
2045            assert_eq!(
2046                (*(*(r1).get("key").unwrap_or(&serde_json::Value::Null))
2047                    .get("remaining")
2048                    .unwrap_or(&serde_json::Value::Null)),
2049                0
2050            );
2051
2052            // Wait for refill interval to elapse
2053            tokio::time::sleep(std::time::Duration::from_millis(150)).await;
2054
2055            // Second verify: should refill to 10 and then decrement -> 9
2056            let r2 = verify_key(&plugin, &ctx, &raw_key, None).await;
2057            assert_eq!(
2058                (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
2059                true,
2060                "Should succeed after refill"
2061            );
2062            assert_eq!(
2063                (*(*(r2).get("key").unwrap_or(&serde_json::Value::Null))
2064                    .get("remaining")
2065                    .unwrap_or(&serde_json::Value::Null)),
2066                9,
2067                "Should be refillAmount - 1 = 9"
2068            );
2069        }
2070
2071        // 6. Permissions: key with {"admin": ["read"]}, verify with
2072        //    {"admin": ["write"]} should fail
2073        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
2074        #[tokio::test]
2075        async fn test_permissions_mismatch_fails() {
2076            let plugin = ApiKeyPlugin::builder().build();
2077            let (ctx, _user, session) = create_test_context_with_user().await;
2078
2079            let (_id, raw_key) = create_key_with_server_fields(
2080                &plugin,
2081                &ctx,
2082                &session.token,
2083                serde_json::json!({ "name": "perm-mismatch" }),
2084                UpdateApiKey {
2085                    permissions: Some(
2086                        serde_json::to_string(&serde_json::json!({ "admin": ["read"] })).unwrap(),
2087                    ),
2088                    ..Default::default()
2089                },
2090            )
2091            .await;
2092
2093            // Verify with matching permission -> pass
2094            let perms_ok = serde_json::json!({ "admin": ["read"] });
2095            let r1 = verify_key(&plugin, &ctx, &raw_key, Some(&perms_ok)).await;
2096            assert_eq!(
2097                (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
2098                true
2099            );
2100
2101            // Verify with mismatched permission -> fail
2102            let perms_fail = serde_json::json!({ "admin": ["write"] });
2103            let r2 = verify_key(&plugin, &ctx, &raw_key, Some(&perms_fail)).await;
2104            assert_eq!(
2105                (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
2106                false
2107            );
2108        }
2109
2110        // 7. Concurrent rate limiting: send 5 sequential verify requests with
2111        //    rateLimitMax=2, only first 2 succeed (sequential proves logic is
2112        //    correct; true concurrency race conditions are documented above).
2113        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
2114        #[tokio::test]
2115        async fn test_concurrent_rate_limiting() {
2116            let plugin = ApiKeyPlugin::builder()
2117                .rate_limit(RateLimitDefaults {
2118                    enabled: true,
2119                    time_window: 60_000.0,
2120                    max_requests: 2.0,
2121                })
2122                .build();
2123            let (ctx, _user, session) = create_test_context_with_user().await;
2124
2125            let (_id, raw_key) = create_key_with_server_fields(
2126                &plugin,
2127                &ctx,
2128                &session.token,
2129                serde_json::json!({ "name": "concurrent-rl" }),
2130                UpdateApiKey {
2131                    rate_limit_enabled: Some(true),
2132                    rate_limit_time_window: Some(60_000.0),
2133                    rate_limit_max: Some(2.0),
2134                    ..Default::default()
2135                },
2136            )
2137            .await;
2138
2139            let mut success_count = 0;
2140            let mut fail_count = 0;
2141
2142            for _ in 0..5 {
2143                let body = verify_key(&plugin, &ctx, &raw_key, None).await;
2144                if (*(body).get("valid").unwrap_or(&serde_json::Value::Null)) == true {
2145                    success_count += 1;
2146                } else {
2147                    fail_count += 1;
2148                    assert_eq!(
2149                        (*(*(body).get("error").unwrap_or(&serde_json::Value::Null))
2150                            .get("code")
2151                            .unwrap_or(&serde_json::Value::Null)),
2152                        "RATE_LIMITED"
2153                    );
2154                }
2155            }
2156
2157            assert_eq!(success_count, 2, "Only 2 out of 5 should succeed");
2158            assert_eq!(fail_count, 3, "3 out of 5 should be rate-limited");
2159        }
2160
2161        // 8. Database compatibility: test delete_expired_api_keys through the
2162        //    in-repo auth store implementation.
2163        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
2164        #[tokio::test]
2165        async fn test_delete_expired_api_keys_memory_adapter() {
2166            let (ctx, fixture_user, session) = create_test_context_with_user().await;
2167            let plugin = ApiKeyPlugin::builder().build();
2168
2169            // Create two keys
2170            let (id1, _) = create_key_and_get_raw(
2171                &plugin,
2172                &ctx,
2173                &session.token,
2174                serde_json::json!({ "name": "will-expire" }),
2175            )
2176            .await;
2177            let (_id2, _) = create_key_and_get_raw(
2178                &plugin,
2179                &ctx,
2180                &session.token,
2181                serde_json::json!({ "name": "wont-expire" }),
2182            )
2183            .await;
2184
2185            // Expire the first key by setting expires_at to the past
2186            let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
2187            ctx.database
2188                .update_api_key(
2189                    &id1,
2190                    UpdateApiKey {
2191                        expires_at: Some(Some(past)),
2192                        ..Default::default()
2193                    },
2194                )
2195                .await
2196                .unwrap();
2197
2198            // Delete expired keys
2199            let deleted = ctx.database.delete_expired_api_keys().await.unwrap();
2200            assert_eq!(deleted, 1, "Should delete exactly 1 expired key");
2201
2202            // Verify only the non-expired key remains
2203            let remaining = ctx
2204                .database
2205                .list_api_keys_by_reference(&fixture_user.id)
2206                .await
2207                .unwrap();
2208            assert_eq!(remaining.len(), 1);
2209        }
2210
2211        // 9. Delete expired: calling the store function directly removes only expired keys
2212        #[tokio::test]
2213        async fn test_delete_expired_removes_only_expired() {
2214            let plugin = ApiKeyPlugin::builder().build();
2215            let (ctx, fixture_user, session) = create_test_context_with_user().await;
2216
2217            // Create two keys, expire one
2218            let (id1, _) = create_key_and_get_raw(
2219                &plugin,
2220                &ctx,
2221                &session.token,
2222                serde_json::json!({ "name": "expired" }),
2223            )
2224            .await;
2225            let (_id2, _) = create_key_and_get_raw(
2226                &plugin,
2227                &ctx,
2228                &session.token,
2229                serde_json::json!({ "name": "active" }),
2230            )
2231            .await;
2232
2233            let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
2234            ctx.database
2235                .update_api_key(
2236                    &id1,
2237                    UpdateApiKey {
2238                        expires_at: Some(Some(past)),
2239                        ..Default::default()
2240                    },
2241                )
2242                .await
2243                .unwrap();
2244
2245            let deleted = ctx.database.delete_expired_api_keys().await.unwrap();
2246            assert_eq!(deleted, 1);
2247
2248            let remaining = ctx
2249                .database
2250                .list_api_keys_by_reference(&fixture_user.id)
2251                .await
2252                .unwrap();
2253            assert_eq!(remaining.len(), 1);
2254        }
2255
2256        // 10. before_request returns None when enableSessionForAPIKeys is false
2257        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
2258        #[tokio::test]
2259        async fn test_before_request_disabled_returns_none() {
2260            let plugin = ApiKeyPlugin::builder().build(); // enable_session_for_api_keys defaults to false
2261            let (ctx, _user, session) = create_test_context_with_user().await;
2262
2263            let (_id, raw_key) = create_key_and_get_raw(
2264                &plugin,
2265                &ctx,
2266                &session.token,
2267                serde_json::json!({ "name": "disabled-session" }),
2268            )
2269            .await;
2270
2271            let mut headers = HashMap::new();
2272            headers.insert("x-api-key".to_owned(), raw_key);
2273            let req = AuthRequest::from_parts(
2274                HttpMethod::Get,
2275                "/get-session".to_owned(),
2276                headers,
2277                None,
2278                HashMap::new(),
2279            );
2280
2281            let action = plugin.before_request(&req, &ctx).await.unwrap();
2282            assert!(
2283                action.is_none(),
2284                "before_request should return None when session emulation is disabled"
2285            );
2286        }
2287
2288        // Upstream reference: @better-auth/api-key :: resolveConfiguration — an absent
2289        // or unknown configId falls back to the default configuration.
2290        #[tokio::test]
2291        async fn test_resolve_configuration_falls_back_to_default() {
2292            let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
2293                config_id: "billing".to_owned(),
2294                ..ApiKeyConfig::default()
2295            });
2296
2297            assert_eq!(
2298                plugin.resolve_configuration(None).unwrap().config_id,
2299                "default"
2300            );
2301            assert_eq!(
2302                plugin
2303                    .resolve_configuration(Some("billing"))
2304                    .unwrap()
2305                    .config_id,
2306                "billing"
2307            );
2308            // Unknown ids fall back rather than erroring.
2309            assert_eq!(
2310                plugin
2311                    .resolve_configuration(Some("nope"))
2312                    .unwrap()
2313                    .config_id,
2314                "default"
2315            );
2316        }
2317
2318        // Upstream reference: @better-auth/api-key :: resolveConfiguration errors when
2319        // no configuration is registered as the default.
2320        #[tokio::test]
2321        async fn test_resolve_configuration_without_default_is_an_error() {
2322            let plugin = ApiKeyPlugin::builder()
2323                .config_id("billing".to_owned())
2324                .build();
2325
2326            let err = plugin.resolve_configuration(None).unwrap_err();
2327            assert_eq!(err.status_code(), 400);
2328            assert_eq!(err.to_string(), "No default api-key configuration found.");
2329        }
2330
2331        // Upstream reference: @better-auth/api-key :: configIdMatches treats a missing
2332        // configId as the default, for keys written before the column existed.
2333        #[tokio::test]
2334        async fn test_config_id_matches_treats_missing_as_default() {
2335            assert!(config_id_matches("", "default"));
2336            assert!(config_id_matches("default", ""));
2337            assert!(config_id_matches("billing", "billing"));
2338            assert!(!config_id_matches("billing", "default"));
2339        }
2340
2341        // Upstream reference: @better-auth/api-key :: create with `references:
2342        // "organization"` requires organizationId.
2343        #[tokio::test]
2344        async fn test_create_for_organization_requires_organization_id() {
2345            let plugin = ApiKeyPlugin::builder()
2346                .references(ApiKeyReferences::Organization)
2347                .build();
2348            let (ctx, _user, session) = create_test_context_with_user().await;
2349
2350            let req = create_auth_request(
2351                HttpMethod::Post,
2352                "/api-key/create",
2353                Some(&session.token),
2354                Some(serde_json::json!({ "name": "org-key" })),
2355                None,
2356            );
2357            let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
2358
2359            assert_eq!(err.status_code(), 400);
2360            assert_eq!(
2361                err.to_string(),
2362                "Organization ID is required for organization-owned API keys."
2363            );
2364        }
2365
2366        // Upstream reference: @better-auth/api-key :: checkOrgApiKeyPermission fails
2367        // when the organization plugin, which supplies the access control, is absent.
2368        #[tokio::test]
2369        async fn test_create_for_organization_requires_the_organization_plugin() {
2370            let plugin = ApiKeyPlugin::builder()
2371                .references(ApiKeyReferences::Organization)
2372                .build();
2373            let (ctx, _user, session) = create_test_context_with_user().await;
2374
2375            let req = create_auth_request(
2376                HttpMethod::Post,
2377                "/api-key/create",
2378                Some(&session.token),
2379                Some(serde_json::json!({ "name": "org-key", "organizationId": "org-1" })),
2380                None,
2381            );
2382            let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
2383
2384            assert_eq!(
2385                err.to_string(),
2386                "Organization plugin is required for organization-owned API keys. Please install and configure the organization plugin."
2387            );
2388        }
2389
2390        // Upstream reference: @better-auth/api-key :: checkOrgApiKeyPermission rejects
2391        // a caller who is not a member of the owning organization.
2392        #[tokio::test]
2393        async fn test_create_for_organization_rejects_non_member() {
2394            let plugin = ApiKeyPlugin::builder()
2395                .references(ApiKeyReferences::Organization)
2396                .build();
2397            let (ctx, _user, session) = create_test_context_with_user().await;
2398
2399            // Stand in for a registered organization plugin.
2400            let mut metadata: HashMap<String, serde_json::Value> = HashMap::new();
2401            drop(metadata.insert(
2402                crate::plugins::organization::METADATA_ENABLED.to_owned(),
2403                serde_json::Value::Bool(true),
2404            ));
2405            let ctx = AuthContext::with_metadata(
2406                Arc::clone(&ctx.config),
2407                Arc::clone(&ctx.database),
2408                metadata,
2409            );
2410
2411            let req = create_auth_request(
2412                HttpMethod::Post,
2413                "/api-key/create",
2414                Some(&session.token),
2415                Some(
2416                    serde_json::json!({ "name": "org-key", "organizationId": "org-the-user-is-not-in" }),
2417                ),
2418                None,
2419            );
2420            let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
2421
2422            assert_eq!(
2423                err.to_string(),
2424                "You are not a member of the organization that owns this API key."
2425            );
2426        }
2427    }
2428
2429    mod verification_tests {
2430        use super::*;
2431
2432        #[tokio::test]
2433        async fn scoped_verification_uses_the_configuration_hashing_and_rejects_other_configs() {
2434            let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
2435                config_id: "machines".to_owned(),
2436                disable_key_hashing: true,
2437                ..Default::default()
2438            });
2439            let (ctx, _, session) = create_test_context_with_user().await;
2440            let (id, key) = create_key_and_get_raw(
2441                &plugin,
2442                &ctx,
2443                &session.token,
2444                serde_json::json!({ "configId": "machines", "name": "machine" }),
2445            )
2446            .await;
2447            let scoped = VerifyApiKey {
2448                key: &key,
2449                config_id: Some("machines"),
2450                permissions: None,
2451            };
2452            let verified = plugin.verify_api_key(&scoped, &ctx).await.unwrap();
2453            assert_eq!(verified.id, id);
2454            assert_eq!(verified.config_id, "machines");
2455            let serialized = serde_json::to_value(verified).unwrap();
2456            assert!(serialized.get("key").is_none());
2457            assert!(serialized.get("keyHash").is_none());
2458
2459            for config_id in [None, Some("default"), Some("unknown")] {
2460                let error = plugin
2461                    .verify_api_key(
2462                        &VerifyApiKey {
2463                            config_id,
2464                            ..scoped
2465                        },
2466                        &ctx,
2467                    )
2468                    .await
2469                    .unwrap_err();
2470                assert!(matches!(
2471                    error,
2472                    ApiKeyVerificationError::Validation(ApiKeyValidationError {
2473                        code: ApiKeyErrorCode::InvalidApiKey,
2474                        ..
2475                    })
2476                ));
2477            }
2478            assert_eq!(
2479                ctx.database
2480                    .get_api_key_by_id(&id)
2481                    .await
2482                    .unwrap()
2483                    .unwrap()
2484                    .request_count,
2485                Some(1.0)
2486            );
2487        }
2488
2489        #[tokio::test]
2490        async fn unscoped_verification_uses_the_issuing_configuration_limits() {
2491            let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
2492                config_id: "machines".to_owned(),
2493                rate_limit: RateLimitDefaults {
2494                    enabled: false,
2495                    ..Default::default()
2496                },
2497                ..Default::default()
2498            });
2499            let (ctx, _, session) = create_test_context_with_user().await;
2500            let (id, key) = create_key_and_get_raw(
2501                &plugin,
2502                &ctx,
2503                &session.token,
2504                serde_json::json!({ "configId": "machines" }),
2505            )
2506            .await;
2507            ctx.database
2508                .update_api_key(
2509                    &id,
2510                    UpdateApiKey {
2511                        rate_limit_enabled: Some(true),
2512                        rate_limit_max: Some(1.0),
2513                        ..Default::default()
2514                    },
2515                )
2516                .await
2517                .unwrap();
2518
2519            let input = VerifyApiKey {
2520                key: &key,
2521                config_id: None,
2522                permissions: None,
2523            };
2524            for _ in 0..3 {
2525                assert_eq!(plugin.verify_api_key(&input, &ctx).await.unwrap().id, id);
2526            }
2527            let error = plugin
2528                .verify_api_key(
2529                    &VerifyApiKey {
2530                        config_id: Some("default"),
2531                        ..input
2532                    },
2533                    &ctx,
2534                )
2535                .await
2536                .unwrap_err();
2537            assert!(matches!(
2538                error,
2539                ApiKeyVerificationError::Validation(ApiKeyValidationError {
2540                    code: ApiKeyErrorCode::InvalidApiKey,
2541                    ..
2542                })
2543            ));
2544            assert_eq!(
2545                ctx.database
2546                    .get_api_key_by_id(&id)
2547                    .await
2548                    .unwrap()
2549                    .unwrap()
2550                    .request_count,
2551                Some(0.0)
2552            );
2553        }
2554
2555        #[tokio::test]
2556        async fn permissions_failure_preserves_usage_and_rate_limit_reports_retry_time() {
2557            let plugin = ApiKeyPlugin::builder().build();
2558            let (ctx, _, session) = create_test_context_with_user().await;
2559            let (id, key) = create_key_with_server_fields(
2560                &plugin,
2561                &ctx,
2562                &session.token,
2563                serde_json::json!({}),
2564                UpdateApiKey {
2565                    remaining: Some(3.0),
2566                    permissions: Some(serde_json::json!({ "nodes": ["read"] }).to_string()),
2567                    rate_limit_max: Some(1.0),
2568                    rate_limit_time_window: Some(60_000.0),
2569                    ..Default::default()
2570                },
2571            )
2572            .await;
2573            let denied = serde_json::json!({ "nodes": ["delete"] });
2574            let allowed = serde_json::json!({ "nodes": ["read"] });
2575            let input = VerifyApiKey {
2576                key: &key,
2577                config_id: None,
2578                permissions: Some(&denied),
2579            };
2580            let error = plugin.verify_api_key(&input, &ctx).await.unwrap_err();
2581            assert!(matches!(
2582                error,
2583                ApiKeyVerificationError::Validation(ApiKeyValidationError {
2584                    code: ApiKeyErrorCode::KeyNotFound,
2585                    ..
2586                })
2587            ));
2588            assert_eq!(
2589                ctx.database
2590                    .get_api_key_by_id(&id)
2591                    .await
2592                    .unwrap()
2593                    .unwrap()
2594                    .remaining,
2595                Some(3.0)
2596            );
2597            let input = VerifyApiKey {
2598                permissions: Some(&allowed),
2599                ..input
2600            };
2601            assert_eq!(
2602                plugin.verify_api_key(&input, &ctx).await.unwrap().remaining,
2603                Some(2.0)
2604            );
2605            let error_2 = plugin.verify_api_key(&input, &ctx).await.unwrap_err();
2606            let ApiKeyVerificationError::Validation(error_2_3) = error_2 else {
2607                panic!("Expected rate limit rejection")
2608            };
2609            assert_eq!(error_2_3.code, ApiKeyErrorCode::RateLimited);
2610            let retry = error_2_3.details.unwrap().try_again_in;
2611            assert!(retry > 0.0 && retry <= 60_000.0);
2612            assert_eq!(
2613                ctx.database
2614                    .get_api_key_by_id(&id)
2615                    .await
2616                    .unwrap()
2617                    .unwrap()
2618                    .remaining,
2619                Some(1.0)
2620            );
2621        }
2622
2623        #[tokio::test]
2624        async fn session_header_selects_a_named_config_without_a_default() {
2625            let plugin = ApiKeyPlugin::builder()
2626                .config_id("machines".to_owned())
2627                .api_key_headers(vec![
2628                    "x-machine-key".to_owned(),
2629                    "x-alternate-key".to_owned(),
2630                ])
2631                .enable_session_for_api_keys(true)
2632                .build();
2633            let (ctx, user, session) = create_test_context_with_user().await;
2634            let (_, key) = create_key_and_get_raw(
2635                &plugin,
2636                &ctx,
2637                &session.token,
2638                serde_json::json!({ "configId": "machines" }),
2639            )
2640            .await;
2641            let mut request =
2642                create_auth_request(HttpMethod::Get, "/get-session", None, None, None);
2643            request
2644                .headers
2645                .insert("x-alternate-key".to_owned(), key.clone());
2646            let result = plugin
2647                .before_request(&request, &ctx)
2648                .await
2649                .unwrap()
2650                .unwrap();
2651            let BeforeRequestAction::Respond(response) = result else {
2652                panic!("Expected virtual session")
2653            };
2654            assert_eq!(response.status, 200);
2655            let body = json_body(&response);
2656            assert_eq!(
2657                (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
2658                    .get("token")
2659                    .unwrap_or(&serde_json::Value::Null)),
2660                key
2661            );
2662            assert_eq!(
2663                (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
2664                    .get("userId")
2665                    .unwrap_or(&serde_json::Value::Null)),
2666                user.id
2667            );
2668            assert_eq!(
2669                (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
2670                    .get("emailVerified")
2671                    .unwrap_or(&serde_json::Value::Null)),
2672                false
2673            );
2674            assert!(
2675                (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
2676                    .get("createdAt")
2677                    .unwrap_or(&serde_json::Value::Null))
2678                .is_string()
2679            );
2680            assert!(
2681                (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
2682                    .get("expiresAt")
2683                    .unwrap_or(&serde_json::Value::Null))
2684                .is_string()
2685            );
2686        }
2687
2688        #[tokio::test]
2689        async fn session_header_cannot_authenticate_a_different_configuration() {
2690            let plugin = ApiKeyPlugin::builder()
2691                .enable_session_for_api_keys(true)
2692                .build()
2693                .configuration(ApiKeyConfig {
2694                    config_id: "machines".to_owned(),
2695                    api_key_headers: vec!["x-machine-key".to_owned()],
2696                    enable_session_for_api_keys: true,
2697                    ..Default::default()
2698                });
2699            let (ctx, _, session) = create_test_context_with_user().await;
2700            let (id, key) = create_key_and_get_raw(
2701                &plugin,
2702                &ctx,
2703                &session.token,
2704                serde_json::json!({ "configId": "machines" }),
2705            )
2706            .await;
2707            let mut request =
2708                create_auth_request(HttpMethod::Get, "/get-session", None, None, None);
2709            request.headers.insert("x-api-key".to_owned(), key);
2710            let result = plugin
2711                .before_request(&request, &ctx)
2712                .await
2713                .unwrap()
2714                .unwrap();
2715            let BeforeRequestAction::Respond(response) = result else {
2716                panic!("Expected credential rejection")
2717            };
2718            assert_eq!(response.status, 401);
2719            assert_eq!(
2720                (*(json_body(&response))
2721                    .get("code")
2722                    .unwrap_or(&serde_json::Value::Null)),
2723                "INVALID_API_KEY"
2724            );
2725            assert_eq!(
2726                ctx.database
2727                    .get_api_key_by_id(&id)
2728                    .await
2729                    .unwrap()
2730                    .unwrap()
2731                    .request_count,
2732                Some(0.0)
2733            );
2734        }
2735
2736        #[tokio::test]
2737        async fn organization_key_verifies_without_emulating_a_user_session() {
2738            let plugin = ApiKeyPlugin::builder()
2739                .references(ApiKeyReferences::Organization)
2740                .enable_session_for_api_keys(true)
2741                .build();
2742            let (ctx, user, _) = create_test_context_with_user().await;
2743            let (key, key_hash, start) =
2744                ApiKeyPlugin::generate_key(&ApiKeyConfig::default(), None).unwrap();
2745            ctx.database
2746                .create_api_key(alibi_core::CreateApiKey {
2747                    // A colliding user ID must not turn an organization key into a user session.
2748                    reference_id: user.id,
2749                    config_id: "default".to_owned(),
2750                    key_hash,
2751                    start: Some(start),
2752                    enabled: true,
2753                    name: None,
2754                    prefix: None,
2755                    expires_at: None,
2756                    remaining: None,
2757                    rate_limit_enabled: false,
2758                    rate_limit_time_window: None,
2759                    rate_limit_max: None,
2760                    refill_interval: None,
2761                    refill_amount: None,
2762                    permissions: None,
2763                    metadata: None,
2764                })
2765                .await
2766                .unwrap();
2767            plugin
2768                .verify_api_key(
2769                    &VerifyApiKey {
2770                        key: &key,
2771                        config_id: None,
2772                        permissions: None,
2773                    },
2774                    &ctx,
2775                )
2776                .await
2777                .unwrap();
2778            let mut request =
2779                create_auth_request(HttpMethod::Get, "/get-session", None, None, None);
2780            request.headers.insert("x-api-key".to_owned(), key);
2781            let result = plugin
2782                .before_request(&request, &ctx)
2783                .await
2784                .unwrap()
2785                .unwrap();
2786            let BeforeRequestAction::Respond(response) = result else {
2787                panic!("Expected organization rejection")
2788            };
2789            assert_eq!(response.status, 401);
2790            assert_eq!(
2791                (*(json_body(&response))
2792                    .get("code")
2793                    .unwrap_or(&serde_json::Value::Null)),
2794                "INVALID_REFERENCE_ID_FROM_API_KEY"
2795            );
2796        }
2797
2798        #[tokio::test]
2799        async fn api_key_initialization_rejects_ambiguous_configurations() {
2800            let (ctx, _, _) = create_test_context_with_user().await;
2801            for config_id in ["", "default"] {
2802                let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
2803                    config_id: config_id.to_owned(),
2804                    ..Default::default()
2805                });
2806                let mut init = alibi_core::AuthInitContext::new(
2807                    Arc::clone(&ctx.config),
2808                    Arc::clone(&ctx.database),
2809                );
2810                assert!(matches!(
2811                    plugin.on_init(&mut init).await,
2812                    Err(AuthError::Config(_))
2813                ));
2814            }
2815        }
2816
2817        #[tokio::test]
2818        async fn verification_preserves_database_failures() {
2819            let connection = alibi_seaorm::Database::connect("sqlite::memory:")
2820                .await
2821                .unwrap();
2822            connection.clone().close().await.unwrap();
2823            let config = Arc::new(crate::plugins::test_helpers::create_test_config());
2824            let database = Arc::new(alibi_seaorm::SeaOrmStore::<TestSchema>::new(
2825                Arc::clone(&config),
2826                connection,
2827            ));
2828            let ctx = AuthContext::new(config, database);
2829            let plugin = ApiKeyPlugin::builder().build();
2830            let error = plugin
2831                .verify_api_key(
2832                    &VerifyApiKey {
2833                        key: "not-looked-up",
2834                        config_id: None,
2835                        permissions: None,
2836                    },
2837                    &ctx,
2838                )
2839                .await
2840                .unwrap_err();
2841            assert!(matches!(
2842                error,
2843                ApiKeyVerificationError::Internal(AuthError::Database(_))
2844            ));
2845        }
2846
2847        #[tokio::test]
2848        async fn verified_session_authenticates_a_protected_plugin_route_without_a_database_session()
2849         {
2850            let plugin = ApiKeyPlugin::builder()
2851                .enable_session_for_api_keys(true)
2852                .build();
2853            let (ctx, user, session) = create_test_context_with_user().await;
2854            let (id, key) =
2855                create_key_and_get_raw(&plugin, &ctx, &session.token, serde_json::json!({})).await;
2856            let mut request =
2857                create_auth_request(HttpMethod::Get, "/api-key/list", None, None, None);
2858            request.headers.insert("x-api-key".to_owned(), key.clone());
2859            let action = plugin
2860                .before_request(&request, &ctx)
2861                .await
2862                .unwrap()
2863                .unwrap();
2864            let BeforeRequestAction::InjectSession { session: session_2 } = action else {
2865                panic!("Expected virtual session")
2866            };
2867            assert_eq!(session_2.token, key);
2868            request.set_virtual_session(session_2);
2869            let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
2870            assert_eq!(response.status, 200);
2871            assert_eq!(
2872                (*(*(*(json_body(&response))
2873                    .get("apiKeys")
2874                    .unwrap_or(&serde_json::Value::Null))
2875                .get(0)
2876                .unwrap_or(&serde_json::Value::Null))
2877                .get("id")
2878                .unwrap_or(&serde_json::Value::Null)),
2879                id
2880            );
2881            assert_eq!(
2882                ctx.database
2883                    .get_user_sessions(&user.id)
2884                    .await
2885                    .unwrap()
2886                    .len(),
2887                1
2888            );
2889        }
2890
2891        // The SDK uses an actual HTTP request. This guards the separate public Rust
2892        // contract: server-only verification without a request can use typed application
2893        // policy from immutable context extensions and preserves quota on rejection.
2894        #[tokio::test]
2895        async fn programmatic_validator_uses_typed_policy_without_a_request() {
2896            use std::sync::atomic::{AtomicBool, Ordering};
2897            struct Policy(AtomicBool);
2898            struct Predicate {
2899                private_policy_secret: String,
2900            }
2901            #[async_trait::async_trait]
2902            impl ApiKeyValidator for Predicate {
2903                async fn validate(
2904                    &self,
2905                    context: &ApiKeyCallbackContext<'_>,
2906                    _key: &str,
2907                ) -> AuthResult<bool> {
2908                    Ok(context.request.is_none()
2909                        && context.configuration_id == "programmatic"
2910                        && context.auth_config.secret == "test-secret-key-at-least-32-chars-long"
2911                        && self.private_policy_secret == "application-private-policy-secret"
2912                        && context
2913                            .extensions
2914                            .get::<Policy>()
2915                            .is_some_and(|policy| policy.0.load(Ordering::SeqCst)))
2916                }
2917            }
2918            let (mut ctx, user, _) = create_test_context_with_user().await;
2919            ctx.extensions.insert(Policy(AtomicBool::new(false)));
2920            let configuration = ApiKeyConfig {
2921                config_id: "programmatic".to_owned(),
2922                custom_api_key_validator: Some(Arc::new(Predicate {
2923                    private_policy_secret: "application-private-policy-secret".to_owned(),
2924                })),
2925                rate_limit: RateLimitDefaults {
2926                    enabled: false,
2927                    ..Default::default()
2928                },
2929                ..Default::default()
2930            };
2931            assert!(!format!("{configuration:?}").contains("application-private-policy-secret"));
2932            let plugin = ApiKeyPlugin::with_config(configuration);
2933            let created = plugin
2934                .create_key(
2935                    &ctx,
2936                    &CreateKeyRequest {
2937                        config_id: Some("programmatic".to_owned()),
2938                        user_id: Some(user.id.clone()),
2939                        remaining: Some(2.0),
2940                        ..Default::default()
2941                    },
2942                )
2943                .await
2944                .unwrap();
2945            let input = VerifyApiKey {
2946                key: &created.key,
2947                config_id: Some("programmatic"),
2948                permissions: None,
2949            };
2950            let rejected = plugin.verify_api_key(&input, &ctx).await.unwrap_err();
2951            assert!(matches!(
2952                rejected,
2953                ApiKeyVerificationError::Validation(ApiKeyValidationError {
2954                    code: ApiKeyErrorCode::KeyNotFound,
2955                    ..
2956                })
2957            ));
2958            assert_eq!(
2959                ctx.database
2960                    .get_api_key_by_id(&created.api_key.id)
2961                    .await
2962                    .unwrap()
2963                    .unwrap()
2964                    .remaining,
2965                Some(2.0)
2966            );
2967            ctx.extensions
2968                .get::<Policy>()
2969                .unwrap()
2970                .0
2971                .store(true, Ordering::SeqCst);
2972            let accepted = plugin.verify_api_key(&input, &ctx).await.unwrap();
2973            assert_eq!(accepted.reference_id, user.id);
2974            assert_eq!(accepted.id, created.api_key.id);
2975            assert_eq!(accepted.remaining, Some(1.0));
2976            assert_eq!(
2977                ctx.database
2978                    .get_api_key_by_id(&created.api_key.id)
2979                    .await
2980                    .unwrap()
2981                    .unwrap()
2982                    .remaining,
2983                Some(1.0)
2984            );
2985        }
2986    }
2987}
2988// LCOV_EXCL_STOP
2989
2990// LCOV_EXCL_START
2991#[cfg(test)]
2992mod crud_tests {
2993    use super::*;
2994    use alibi_core::{AuthConfig, CreateSession, CreateUser, HttpMethod};
2995    use chrono::{Duration, Utc};
2996    use serde_json::json;
2997    use std::collections::HashMap;
2998    use std::sync::Arc;
2999
3000    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
3001
3002    async fn context() -> (AuthContext<TestSchema>, String, String) {
3003        let database = crate::plugins::test_helpers::create_test_database().await;
3004        let user = database
3005            .create_user(
3006                CreateUser::new()
3007                    .with_email("crud@example.com")
3008                    .with_name("CRUD user"),
3009            )
3010            .await
3011            .unwrap();
3012        let user_id = user.id().to_string();
3013        let session = database
3014            .create_session(CreateSession {
3015                additional_fields: alibi_core::field_policy::FieldValues::default(),
3016                token: None,
3017                active_team_id: None,
3018                user_id: user_id.clone(),
3019                expires_at: Utc::now() + Duration::hours(1),
3020                ip_address: None,
3021                user_agent: None,
3022                impersonated_by: None,
3023                active_organization_id: None,
3024            })
3025            .await
3026            .unwrap();
3027        let token = alibi_core::entity::AuthSession::token(&session).to_owned();
3028        (
3029            AuthContext::new(
3030                Arc::new(AuthConfig::new("a-secret-that-is-at-least-32-characters")),
3031                database,
3032            ),
3033            user_id,
3034            token,
3035        )
3036    }
3037
3038    fn request(token: &str, path: &str, body: &serde_json::Value) -> AuthRequest {
3039        AuthRequest::from_parts(
3040            HttpMethod::Post,
3041            path.to_owned(),
3042            HashMap::from([(
3043                "cookie".to_owned(),
3044                format!(
3045                    "better-auth.session_token={}",
3046                    alibi_core::utils::cookie_utils::sign_cookie_value(
3047                        token,
3048                        "a-secret-that-is-at-least-32-characters"
3049                    )
3050                ),
3051            )]),
3052            Some(serde_json::to_vec(&body).unwrap()),
3053            HashMap::new(),
3054        )
3055    }
3056
3057    async fn server_key(
3058        plugin: &ApiKeyPlugin,
3059        ctx: &AuthContext<TestSchema>,
3060        user_id: &str,
3061        config_id: &str,
3062    ) -> CreateKeyResponse {
3063        plugin
3064            .create_key(
3065                ctx,
3066                &CreateKeyRequest {
3067                    user_id: Some(user_id.to_owned()),
3068                    config_id: Some(config_id.to_owned()),
3069                    ..Default::default()
3070                },
3071            )
3072            .await
3073            .unwrap()
3074    }
3075
3076    #[tokio::test]
3077    async fn list_without_configuration_includes_all_user_configurations() {
3078        let (ctx, user_id, _) = context().await;
3079        let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
3080            config_id: "secondary".to_owned(),
3081            ..Default::default()
3082        });
3083        server_key(&plugin, &ctx, &user_id, "default").await;
3084        server_key(&plugin, &ctx, &user_id, "secondary").await;
3085        let list = list_keys_core(&user_id, &ListKeysQuery::default(), &plugin, &ctx)
3086            .await
3087            .unwrap();
3088        assert_eq!(list.total, 2);
3089        let list_2 = list_keys_core(
3090            &user_id,
3091            &ListKeysQuery {
3092                config_id: Some("secondary".to_owned()),
3093                ..Default::default()
3094            },
3095            &plugin,
3096            &ctx,
3097        )
3098        .await
3099        .unwrap();
3100        assert_eq!(list_2.total, 1);
3101        assert_eq!(
3102            (list_2.api_keys)
3103                .first()
3104                .expect("fixture contains the requested index")
3105                .config_id,
3106            "secondary"
3107        );
3108        let list_3 = list_keys_core(
3109            &user_id,
3110            &ListKeysQuery {
3111                config_id: Some("missing".to_owned()),
3112                ..Default::default()
3113            },
3114            &plugin,
3115            &ctx,
3116        )
3117        .await
3118        .unwrap();
3119        assert_eq!(list_3.total, 0);
3120
3121        let no_default = ApiKeyPlugin::builder()
3122            .config_id("secondary".to_owned())
3123            .build();
3124        assert_eq!(
3125            list_keys_core(&user_id, &ListKeysQuery::default(), &no_default, &ctx)
3126                .await
3127                .unwrap()
3128                .total,
3129            2
3130        );
3131    }
3132
3133    #[tokio::test]
3134    async fn http_requests_cannot_impersonate_users_or_change_server_permissions() {
3135        let (ctx, user_id, token) = context().await;
3136        let plugin = ApiKeyPlugin::builder().build();
3137        let create = request(&token, "/api-key/create", &(json!({"userId":user_id})));
3138        assert_eq!(
3139            plugin
3140                .handle_create(&create, &ctx)
3141                .await
3142                .unwrap_err()
3143                .status_code(),
3144            401
3145        );
3146        let key = server_key(&plugin, &ctx, &user_id, "default").await;
3147        let update = request(
3148            &token,
3149            "/api-key/update",
3150            &(json!({"keyId":key.api_key.id,"userId":"someone-else","enabled":false})),
3151        );
3152        assert_eq!(
3153            plugin
3154                .handle_update(&update, &ctx)
3155                .await
3156                .unwrap_err()
3157                .status_code(),
3158            401
3159        );
3160        let update_2 = request(
3161            &token,
3162            "/api-key/update",
3163            &(json!({"keyId":key.api_key.id,"permissions":null})),
3164        );
3165        assert_eq!(
3166            plugin
3167                .handle_update(&update_2, &ctx)
3168                .await
3169                .unwrap_err()
3170                .to_string(),
3171            ApiKeyErrorCode::ServerOnlyProperty.message()
3172        );
3173        let update_3 = request(&token, "/api-key/update", &(json!({"keyId":"missing"})));
3174        assert_eq!(
3175            plugin
3176                .handle_update(&update_3, &ctx)
3177                .await
3178                .unwrap_err()
3179                .status_code(),
3180            404
3181        );
3182    }
3183
3184    #[tokio::test]
3185    async fn metadata_can_be_cleared_and_disabled_metadata_is_ignored_on_update() {
3186        let (ctx, user_id, token) = context().await;
3187        let enabled = ApiKeyPlugin::builder().enable_metadata(true).build();
3188        let key = enabled
3189            .create_key(
3190                &ctx,
3191                &CreateKeyRequest {
3192                    user_id: Some(user_id.clone()),
3193                    metadata: Some(json!(["initial"]).into()),
3194                    ..Default::default()
3195                },
3196            )
3197            .await
3198            .unwrap();
3199        assert_eq!(key.api_key.metadata, Some(json!(["initial"])));
3200        let update = request(
3201            &token,
3202            "/api-key/update",
3203            &(json!({"keyId":key.api_key.id,"metadata":null})),
3204        );
3205        let response = enabled.handle_update(&update, &ctx).await.unwrap();
3206        assert!(
3207            (*(serde_json::from_slice::<serde_json::Value>(&response.body).unwrap())
3208                .get("metadata")
3209                .unwrap_or(&serde_json::Value::Null))
3210            .is_null()
3211        );
3212        let disabled = ApiKeyPlugin::builder().build();
3213        let update_2 = request(
3214            &token,
3215            "/api-key/update",
3216            &(json!({"keyId":key.api_key.id,"metadata":{"ignored":true},"enabled":false})),
3217        );
3218        let response_2 = disabled.handle_update(&update_2, &ctx).await.unwrap();
3219        let result: serde_json::Value = serde_json::from_slice(&response_2.body).unwrap();
3220        assert!((*(result).get("metadata").unwrap_or(&serde_json::Value::Null)).is_null());
3221        assert_eq!(
3222            (*(result).get("enabled").unwrap_or(&serde_json::Value::Null)),
3223            false
3224        );
3225        let update_3 = request(
3226            &token,
3227            "/api-key/update",
3228            &(json!({"keyId":key.api_key.id,"metadata":null})),
3229        );
3230        assert_eq!(
3231            disabled
3232                .handle_update(&update_3, &ctx)
3233                .await
3234                .unwrap_err()
3235                .to_string(),
3236            ApiKeyErrorCode::NoValuesToUpdate.message()
3237        );
3238    }
3239
3240    #[tokio::test]
3241    async fn trusted_creation_and_update_preserve_permissions_and_fractional_expiration() {
3242        let (ctx, user_id, _) = context().await;
3243        let plugin = ApiKeyPlugin::builder()
3244            .key_expiration(KeyExpirationConfig {
3245                min_expires_in: 0.0,
3246                ..Default::default()
3247            })
3248            .build();
3249        let before = Utc::now();
3250        let key = plugin
3251            .create_key(
3252                &ctx,
3253                &CreateKeyRequest {
3254                    user_id: Some(user_id.clone()),
3255                    expires_in: Some(86400.25),
3256                    remaining: Some(3.0),
3257                    permissions: Some(ApiKeyPermissions::from([(
3258                        "device".to_owned(),
3259                        vec!["read".to_owned()],
3260                    )])),
3261                    ..Default::default()
3262                },
3263            )
3264            .await
3265            .unwrap();
3266        assert_eq!(key.api_key.remaining, Some(3.0));
3267        assert_eq!(key.api_key.permissions, Some(json!({"device":["read"]})));
3268        let expires_at =
3269            chrono::DateTime::parse_from_rfc3339(key.api_key.expires_at.as_deref().unwrap())
3270                .unwrap()
3271                .with_timezone(&Utc);
3272        assert!((expires_at - before).num_milliseconds() >= 86_400_249);
3273        assert!((expires_at - Utc::now()).num_milliseconds() <= 86_400_250);
3274        let updated = plugin
3275            .update_key(
3276                &ctx,
3277                &UpdateKeyRequest {
3278                    key_id: key.api_key.id.clone(),
3279                    user_id: Some(user_id),
3280                    remaining: Some(5.0),
3281                    permissions: Some(None),
3282                    expires_in: Some(None),
3283                    ..Default::default()
3284                },
3285            )
3286            .await
3287            .unwrap();
3288        assert_eq!(updated.remaining, Some(5.0));
3289        assert_eq!(updated.permissions, Some(serde_json::Value::Null));
3290        assert_eq!(updated.expires_at, None);
3291        let stored = ctx
3292            .database
3293            .get_api_key_by_id(&key.api_key.id)
3294            .await
3295            .unwrap()
3296            .unwrap();
3297        assert_ne!(stored.key_hash, key.key);
3298    }
3299
3300    #[tokio::test]
3301    async fn list_rejects_invalid_pagination_instead_of_ignoring_it() {
3302        let (ctx, _, token) = context().await;
3303        let plugin = ApiKeyPlugin::builder().build();
3304        for (field, value) in [
3305            ("limit", "-1"),
3306            ("offset", "1.5"),
3307            ("limit", "bad"),
3308            ("sortDirection", "sideways"),
3309        ] {
3310            let mut req = request(&token, "/api-key/list", &(json!({})));
3311            req.query.insert(field.to_owned(), value.to_owned());
3312            let response = plugin.handle_list(&req, &ctx).await.unwrap();
3313            assert_eq!(response.status, 400);
3314            assert_eq!(
3315                (*(serde_json::from_slice::<serde_json::Value>(&response.body).unwrap())
3316                    .get("code")
3317                    .unwrap_or(&serde_json::Value::Null)),
3318                "VALIDATION_ERROR"
3319            );
3320        }
3321    }
3322
3323    #[tokio::test]
3324    async fn forced_cleanup_preserves_rows_on_store_failure_and_retries_without_throttle() {
3325        use alibi_seaorm::sea_orm::{ConnectionTrait, DbBackend, Statement};
3326        let config = Arc::new(AuthConfig::new("forced-cleanup-application-secret32"));
3327        let connection = alibi_seaorm::Database::connect("sqlite::memory:")
3328            .await
3329            .unwrap();
3330        alibi_seaorm::store::__private_test_support::migrator::run_migrations(&connection)
3331            .await
3332            .unwrap();
3333        let database = Arc::new(alibi_seaorm::SeaOrmStore::<TestSchema>::new(
3334            std::sync::Arc::clone(&config),
3335            connection.clone(),
3336        ));
3337        let ctx = AuthContext::new(config, database);
3338        let owner = ctx
3339            .database
3340            .create_user(CreateUser::new().with_email("cleanup@native.local"))
3341            .await
3342            .unwrap();
3343        let plugin = ApiKeyPlugin::with_config(ApiKeyConfig {
3344            key_expiration: KeyExpirationConfig {
3345                min_expires_in: 0.0,
3346                ..Default::default()
3347            },
3348            ..Default::default()
3349        });
3350        let key = plugin
3351            .create_key(
3352                &ctx,
3353                &CreateKeyRequest {
3354                    user_id: Some(owner.id().to_string()),
3355                    name: Some("expired".into()),
3356                    ..Default::default()
3357                },
3358            )
3359            .await
3360            .unwrap();
3361        let expired = ctx
3362            .database
3363            .update_api_key(
3364                &key.api_key.id,
3365                alibi_core::UpdateApiKey {
3366                    expires_at: Some(Some("1970-01-01T00:00:00.000Z".into())),
3367                    ..Default::default()
3368                },
3369            )
3370            .await
3371            .unwrap();
3372        connection.execute_raw(Statement::from_string(DbBackend::Sqlite,"CREATE TRIGGER reject_api_key_cleanup BEFORE DELETE ON api_keys BEGIN SELECT RAISE(ABORT,'application cleanup rejected'); END")).await.unwrap();
3373        let failed = plugin.delete_all_expired_api_keys(&ctx).await;
3374        assert!(failed.success);
3375        assert!(failed.error.is_none());
3376        let retained = ctx
3377            .database
3378            .get_api_key_by_id(&key.api_key.id)
3379            .await
3380            .unwrap()
3381            .unwrap();
3382        assert_eq!(
3383            alibi_core::utils::json::to_value(&retained).unwrap(),
3384            alibi_core::utils::json::to_value(&expired).unwrap()
3385        );
3386        connection
3387            .execute_raw(Statement::from_string(
3388                DbBackend::Sqlite,
3389                "DROP TRIGGER reject_api_key_cleanup",
3390            ))
3391            .await
3392            .unwrap();
3393        let retry = plugin.delete_all_expired_api_keys(&ctx).await;
3394        assert!(retry.success);
3395        assert!(retry.error.is_none());
3396        assert!(
3397            ctx.database
3398                .get_api_key_by_id(&key.api_key.id)
3399                .await
3400                .unwrap()
3401                .is_none()
3402        );
3403        assert_eq!(
3404            ctx.database
3405                .get_user_by_id(owner.id().as_ref())
3406                .await
3407                .unwrap()
3408                .unwrap(),
3409            owner
3410        );
3411    }
3412    struct ApplicationKeyStorage {
3413        cache: Arc<alibi_core::store::MemoryCacheAdapter>,
3414        fail_writes: std::sync::atomic::AtomicBool,
3415    }
3416    #[async_trait::async_trait]
3417    impl ApiKeyStorage for ApplicationKeyStorage {
3418        async fn get(&self, key: &str) -> AuthResult<Option<String>> {
3419            use alibi_core::store::CacheAdapter as _;
3420            self.cache.get(key).await
3421        }
3422        async fn set(&self, key: &str, value: &str, ttl: Option<Duration>) -> AuthResult<()> {
3423            use alibi_core::store::CacheAdapter as _;
3424            if self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) {
3425                return Err(AuthError::internal("application storage unavailable"));
3426            }
3427            match ttl {
3428                Some(ttl) => self.cache.set(key, value, ttl).await,
3429                None => self.cache.set_without_expiry(key, value).await,
3430            }
3431        }
3432        async fn delete(&self, key: &str) -> AuthResult<()> {
3433            use alibi_core::store::CacheAdapter as _;
3434            self.cache.delete(key).await
3435        }
3436    }
3437    #[derive(Default)]
3438    struct ApplicationCompletions(std::sync::Mutex<Vec<alibi_core::BackgroundTaskCompletion>>);
3439    impl alibi_core::BackgroundTaskHandler for ApplicationCompletions {
3440        fn handle(&self, completion: alibi_core::BackgroundTaskCompletion) -> AuthResult<()> {
3441            self.0.lock().unwrap().push(completion);
3442            Ok(())
3443        }
3444    }
3445    impl ApplicationCompletions {
3446        async fn drain(&self) {
3447            let tasks = std::mem::take(&mut *self.0.lock().unwrap());
3448            for task in tasks {
3449                task.await.unwrap();
3450            }
3451        }
3452    }
3453
3454    #[tokio::test]
3455    async fn source_boxed_metadata_rows_list_successfully_without_mutating_storage() {
3456        use alibi_core::store::{CacheAdapter, MemoryCacheAdapter};
3457        let (ctx, owner, token) = context().await;
3458        let cache = Arc::new(MemoryCacheAdapter::new());
3459        let plugin = ApiKeyPlugin::with_config(ApiKeyConfig {
3460            storage: ApiKeyStorageMode::SecondaryStorage,
3461            secondary_storage: Some(cache.clone()),
3462            ..Default::default()
3463        });
3464        let mut rows = Vec::new();
3465        let mut expected = std::collections::BTreeMap::new();
3466        // Actual pinned Source trusted creation admits boxed String("10"),
3467        // String("2"), Number(3), then serializes these primitive metadata
3468        // values. Their relational comparator is cyclic. Listing still
3469        // succeeds; no cross-engine total ordering is specified.
3470        for index in 0..64 {
3471            let created = server_key(&plugin, &ctx, &owner, "default").await;
3472            let id = format!("api-key:by-id:{}", created.api_key.id);
3473            let hash = format!("api-key:{}", ApiKeyPlugin::hash_key(&created.key));
3474            let mut row: serde_json::Value =
3475                serde_json::from_str(&cache.get(&id).await.unwrap().unwrap()).unwrap();
3476            *row.get_mut("metadata").unwrap() = match index % 6 {
3477                0 => json!("10"),
3478                1 => json!("2"),
3479                2 => json!(3),
3480                3 => json!(null),
3481                4 => json!("0"),
3482                _ => json!({"literal":"object"}),
3483            };
3484            let value = serde_json::to_string(&row).unwrap();
3485            cache.set_without_expiry(&id, &value).await.unwrap();
3486            cache.set_without_expiry(&hash, &value).await.unwrap();
3487            rows.push((id, value.clone()));
3488            rows.push((hash, value));
3489            expected.insert(
3490                created.api_key.id,
3491                [
3492                    json!(10),
3493                    json!(2),
3494                    json!(3),
3495                    json!(null),
3496                    json!(0),
3497                    json!({"literal":"object"}),
3498                ]
3499                .get(index % 6)
3500                .unwrap()
3501                .clone(),
3502            );
3503        }
3504        let reference = format!("api-key:by-ref:{owner}");
3505        let reference_before = cache.get(&reference).await.unwrap();
3506        for direction in ["asc", "desc"] {
3507            let mut req = request(&token, "/api-key/list", &json!(null));
3508            req.method = HttpMethod::Get;
3509            req.body = None;
3510            req.query = HashMap::from([
3511                ("sortBy".to_owned(), "metadata".to_owned()),
3512                ("sortDirection".to_owned(), direction.to_owned()),
3513            ]);
3514            let response = plugin.handle_list(&req, &ctx).await.unwrap();
3515            assert_eq!(response.status, 200);
3516            let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
3517            assert_eq!(body.get("total").unwrap(), 64);
3518            let actual: std::collections::BTreeMap<_, _> = body
3519                .get("apiKeys")
3520                .unwrap()
3521                .as_array()
3522                .unwrap()
3523                .iter()
3524                .map(|row| {
3525                    (
3526                        row.get("id").unwrap().as_str().unwrap().to_owned(),
3527                        row.get("metadata").unwrap().clone(),
3528                    )
3529                })
3530                .collect();
3531            assert_eq!(actual, expected);
3532            for (index, value) in &rows {
3533                assert_eq!(
3534                    cache.get(index).await.unwrap().as_deref(),
3535                    Some(value.as_str())
3536                );
3537            }
3538            assert_eq!(cache.get(&reference).await.unwrap(), reference_before);
3539            assert!(
3540                ctx.database
3541                    .list_api_keys_by_reference(&owner)
3542                    .await
3543                    .unwrap()
3544                    .is_empty()
3545            );
3546        }
3547        // Numeric metadata has an independent total ordering. Exercise a list
3548        // larger than 64 entries, then assert a page in both directions.
3549        for _ in 64..96 {
3550            let created = server_key(&plugin, &ctx, &owner, "default").await;
3551            let id = format!("api-key:by-id:{}", created.api_key.id);
3552            let hash = format!("api-key:{}", ApiKeyPlugin::hash_key(&created.key));
3553            let value = cache.get(&id).await.unwrap().unwrap();
3554            rows.push((id, value.clone()));
3555            rows.push((hash, value));
3556        }
3557        let mut ordered = std::collections::BTreeMap::new();
3558        for (index, pair) in rows.as_chunks_mut::<2>().0.iter_mut().enumerate() {
3559            let rank = (index * 37) % 96;
3560            let mut row: serde_json::Value =
3561                serde_json::from_str(&pair.first().unwrap().1).unwrap();
3562            drop(ordered.insert(rank, row.get("id").unwrap().clone()));
3563            *row.get_mut("metadata").unwrap() = json!(rank);
3564            let serialized = serde_json::to_string(&row).unwrap();
3565            for (key, value) in pair {
3566                cache.set_without_expiry(key, &serialized).await.unwrap();
3567                *value = serialized.clone();
3568            }
3569        }
3570        let reference_before = cache.get(&reference).await.unwrap();
3571        for direction in ["asc", "desc"] {
3572            let mut req = request(&token, "/api-key/list", &json!(null));
3573            req.method = HttpMethod::Get;
3574            req.body = None;
3575            req.query = HashMap::from([
3576                ("sortBy".into(), "metadata".into()),
3577                ("sortDirection".into(), direction.into()),
3578                ("offset".into(), "11".into()),
3579                ("limit".into(), "9".into()),
3580            ]);
3581            let response = plugin.handle_list(&req, &ctx).await.unwrap();
3582            assert_eq!(response.status, 200);
3583            let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
3584            assert_eq!(body.get("total").unwrap(), 96);
3585            let page = body.get("apiKeys").unwrap().as_array().unwrap();
3586            assert_eq!(page.len(), 9);
3587            for (offset, row) in page.iter().enumerate() {
3588                let rank = if direction == "asc" {
3589                    11 + offset
3590                } else {
3591                    84 - offset
3592                };
3593                assert_eq!(row.get("id").unwrap(), ordered.get(&rank).unwrap());
3594                assert_eq!(row.get("metadata").unwrap(), &json!(rank));
3595            }
3596            for (key, value) in &rows {
3597                assert_eq!(
3598                    cache.get(key).await.unwrap().as_deref(),
3599                    Some(value.as_str())
3600                );
3601            }
3602            assert_eq!(cache.get(&reference).await.unwrap(), reference_before);
3603        }
3604    }
3605
3606    #[tokio::test]
3607    async fn application_storage_preserves_authority_indexes_and_usage() {
3608        use alibi_core::store::{CacheAdapter, MemoryCacheAdapter};
3609        // HTTP creation and trusted verification use the real plugin and stores;
3610        // independent database reads prove secondary-only keys create no SQL rows.
3611        for (fallback, custom, deferred) in [
3612            (false, false, false),
3613            (true, false, false),
3614            (false, true, false),
3615            (true, true, false),
3616            (false, true, true),
3617            (true, true, true),
3618        ] {
3619            let (mut ctx, owner, token) = context().await;
3620            let completions = Arc::new(ApplicationCompletions::default());
3621            Arc::make_mut(&mut ctx.config).background_tasks = Some(completions.clone());
3622            let outsider = ctx
3623                .database
3624                .create_user(
3625                    CreateUser::new()
3626                        .with_email("outsider@example.com")
3627                        .with_name("outsider"),
3628                )
3629                .await
3630                .unwrap();
3631            let cache = Arc::new(MemoryCacheAdapter::new());
3632            let decoy = Arc::new(MemoryCacheAdapter::new());
3633            let application = Arc::new(ApplicationKeyStorage {
3634                cache: cache.clone(),
3635                fail_writes: std::sync::atomic::AtomicBool::new(false),
3636            });
3637            let plugin = ApiKeyPlugin::with_config(ApiKeyConfig {
3638                storage: ApiKeyStorageMode::SecondaryStorage,
3639                secondary_storage: Some(
3640                    if custom { decoy.clone() } else { cache.clone() } as Arc<dyn CacheAdapter>
3641                ),
3642                custom_storage: custom.then(|| application.clone() as Arc<dyn ApiKeyStorage>),
3643                fallback_to_database: fallback,
3644                defer_updates: deferred,
3645                rate_limit: RateLimitDefaults {
3646                    enabled: false,
3647                    ..Default::default()
3648                },
3649                ..Default::default()
3650            });
3651            let response = plugin
3652                .handle_create(
3653                    &request(&token, "/api-key/create", &json!({"name":"stored"})),
3654                    &ctx,
3655                )
3656                .await
3657                .unwrap();
3658            let created: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
3659            let id = created.get("id").unwrap().as_str().unwrap();
3660            let raw = created.get("key").unwrap().as_str().unwrap();
3661            plugin
3662                .update_key(
3663                    &ctx,
3664                    &UpdateKeyRequest {
3665                        key_id: id.to_owned(),
3666                        user_id: Some(owner.clone()),
3667                        remaining: Some(2.0),
3668                        ..Default::default()
3669                    },
3670                )
3671                .await
3672                .unwrap();
3673            let hash = ApiKeyPlugin::hash_key(raw);
3674            let by_id = format!("api-key:by-id:{id}");
3675            let by_hash = format!("api-key:{hash}");
3676            let by_ref = format!("api-key:by-ref:{owner}");
3677            let initial = cache.get(&by_id).await.unwrap().unwrap();
3678            assert_eq!(cache.get(&by_hash).await.unwrap(), Some(initial.clone()));
3679            assert_eq!(
3680                ctx.database.get_api_key_by_id(id).await.unwrap().is_some(),
3681                fallback
3682            );
3683            assert_eq!(cache.get(&by_ref).await.unwrap().is_none(), fallback);
3684            assert!(
3685                get_key_core(id, None, &outsider.id().to_string(), &plugin, &ctx)
3686                    .await
3687                    .is_err()
3688            );
3689            assert_eq!(cache.get(&by_id).await.unwrap(), Some(initial.clone()));
3690            assert!(
3691                plugin
3692                    .verify_api_key(
3693                        &VerifyApiKey {
3694                            key: raw,
3695                            config_id: Some("wrong"),
3696                            permissions: None
3697                        },
3698                        &ctx
3699                    )
3700                    .await
3701                    .is_err()
3702            );
3703            assert_eq!(cache.get(&by_id).await.unwrap(), Some(initial));
3704            let list = list_keys_core(&owner, &ListKeysQuery::default(), &plugin, &ctx)
3705                .await
3706                .unwrap();
3707            assert_eq!(list.total, 1);
3708            let input = VerifyApiKey {
3709                key: raw,
3710                config_id: None,
3711                permissions: None,
3712            };
3713            assert_eq!(
3714                plugin.verify_api_key(&input, &ctx).await.unwrap().remaining,
3715                Some(1.0)
3716            );
3717            completions.drain().await;
3718            assert!(decoy.get(&by_id).await.unwrap().is_none());
3719            let row: alibi_core::ApiKey =
3720                serde_json::from_str(&cache.get(&by_id).await.unwrap().unwrap()).unwrap();
3721            assert_eq!(row.remaining, Some(1.0));
3722            assert_eq!(row.reference_id, owner);
3723            assert_eq!(
3724                cache.get(&by_hash).await.unwrap(),
3725                cache.get(&by_id).await.unwrap()
3726            );
3727            if fallback {
3728                let persisted = ctx.database.get_api_key_by_id(id).await.unwrap().unwrap();
3729                let mut persisted_json = serde_json::to_value(&persisted).unwrap();
3730                // The database model stores JSON text; the public application
3731                // storage contract stores the actual metadata value.
3732                *persisted_json.get_mut("metadata").unwrap() =
3733                    serde_json::from_str(persisted.metadata.as_deref().unwrap_or("null")).unwrap();
3734                assert_eq!(persisted_json, serde_json::to_value(&row).unwrap());
3735                cache.delete(&by_id).await.unwrap();
3736                cache.delete(&by_hash).await.unwrap();
3737                assert_eq!(
3738                    get_key_core(id, None, &owner, &plugin, &ctx)
3739                        .await
3740                        .unwrap()
3741                        .remaining,
3742                    Some(1.0)
3743                );
3744                assert_eq!(
3745                    cache.get(&by_hash).await.unwrap(),
3746                    cache.get(&by_id).await.unwrap()
3747                );
3748            }
3749            assert_eq!(
3750                plugin.verify_api_key(&input, &ctx).await.unwrap().remaining,
3751                Some(0.0)
3752            );
3753            completions.drain().await;
3754            assert!(plugin.verify_api_key(&input, &ctx).await.is_err());
3755            completions.drain().await;
3756            assert!(cache.get(&by_id).await.unwrap().is_none());
3757            assert!(cache.get(&by_hash).await.unwrap().is_none());
3758            assert!(cache.get(&by_ref).await.unwrap().is_none());
3759            assert!(ctx.database.get_api_key_by_id(id).await.unwrap().is_none());
3760            if custom {
3761                let target = plugin
3762                    .create_key(
3763                        &ctx,
3764                        &CreateKeyRequest {
3765                            user_id: Some(owner.clone()),
3766                            remaining: Some(4.0),
3767                            ..Default::default()
3768                        },
3769                    )
3770                    .await
3771                    .unwrap();
3772                let raw = target.key.as_str();
3773                let key_id = &target.api_key.id;
3774                let id_index = format!("api-key:by-id:{key_id}");
3775                let hash_index = format!("api-key:{}", ApiKeyPlugin::hash_key(raw));
3776                let before = cache.get(&id_index).await.unwrap();
3777                application
3778                    .fail_writes
3779                    .store(true, std::sync::atomic::Ordering::SeqCst);
3780                let input = VerifyApiKey {
3781                    key: raw,
3782                    config_id: None,
3783                    permissions: None,
3784                };
3785                let failed = plugin.verify_api_key(&input, &ctx).await;
3786                assert_eq!(failed.is_ok(), deferred && !fallback);
3787                completions.drain().await;
3788                assert_eq!(cache.get(&id_index).await.unwrap(), before);
3789                assert_eq!(cache.get(&hash_index).await.unwrap(), before);
3790                if fallback {
3791                    assert_eq!(
3792                        ctx.database
3793                            .get_api_key_by_id(key_id)
3794                            .await
3795                            .unwrap()
3796                            .unwrap()
3797                            .remaining,
3798                        Some(3.0)
3799                    );
3800                } else {
3801                    assert!(
3802                        ctx.database
3803                            .get_api_key_by_id(key_id)
3804                            .await
3805                            .unwrap()
3806                            .is_none()
3807                    );
3808                }
3809                application
3810                    .fail_writes
3811                    .store(false, std::sync::atomic::Ordering::SeqCst);
3812                let retried = plugin.verify_api_key(&input, &ctx).await.unwrap();
3813                assert_eq!(retried.remaining, Some(if fallback { 2.0 } else { 3.0 }));
3814                completions.drain().await;
3815                assert_eq!(
3816                    cache.get(&hash_index).await.unwrap(),
3817                    cache.get(&id_index).await.unwrap()
3818                );
3819                let row: alibi_core::ApiKey =
3820                    serde_json::from_str(&cache.get(&id_index).await.unwrap().unwrap()).unwrap();
3821                assert_eq!(row.remaining, retried.remaining);
3822            }
3823        }
3824        // Secondary-only rate limiting owns its own update path. A denied
3825        // request must not spend quota, and an elapsed window must refill once.
3826        let (ctx, owner, _) = context().await;
3827        let cache = Arc::new(MemoryCacheAdapter::new());
3828        let plugin = ApiKeyPlugin::with_config(ApiKeyConfig {
3829            storage: ApiKeyStorageMode::SecondaryStorage,
3830            secondary_storage: Some(cache.clone()),
3831            ..Default::default()
3832        });
3833        let created = plugin
3834            .create_key(
3835                &ctx,
3836                &CreateKeyRequest {
3837                    user_id: Some(owner.clone()),
3838                    remaining: Some(2.0),
3839                    rate_limit_enabled: Some(true),
3840                    rate_limit_time_window: Some(60_000.0),
3841                    rate_limit_max: Some(1.0),
3842                    refill_interval: Some(60_000.0),
3843                    refill_amount: Some(5.0),
3844                    ..Default::default()
3845                },
3846            )
3847            .await
3848            .unwrap();
3849        let by_id = format!("api-key:by-id:{}", created.api_key.id);
3850        let by_hash = format!("api-key:{}", ApiKeyPlugin::hash_key(&created.key));
3851        let input = VerifyApiKey {
3852            key: &created.key,
3853            config_id: None,
3854            permissions: None,
3855        };
3856        let first = plugin.verify_api_key(&input, &ctx).await.unwrap();
3857        assert_eq!(first.remaining, Some(1.0));
3858        assert_eq!(first.request_count, Some(1.0));
3859        let before = cache.get(&by_id).await.unwrap();
3860        assert_eq!(cache.get(&by_hash).await.unwrap(), before);
3861        let denied = plugin.verify_api_key(&input, &ctx).await.unwrap_err();
3862        let ApiKeyVerificationError::Validation(denied) = denied else {
3863            panic!("Expected rate-limit validation");
3864        };
3865        assert_eq!(denied.code, ApiKeyErrorCode::RateLimited);
3866        assert!(denied.details.unwrap().try_again_in > 0.0);
3867        assert_eq!(cache.get(&by_id).await.unwrap(), before);
3868        assert_eq!(cache.get(&by_hash).await.unwrap(), before);
3869        let mut elapsed: alibi_core::ApiKey =
3870            serde_json::from_str(before.as_deref().unwrap()).unwrap();
3871        let previous = (chrono::Utc::now() - chrono::Duration::minutes(2)).to_rfc3339();
3872        elapsed.last_request = Some(previous.clone());
3873        elapsed.last_refill_at = Some(previous.clone());
3874        let seeded = serde_json::to_string(&elapsed).unwrap();
3875        cache.set_without_expiry(&by_id, &seeded).await.unwrap();
3876        cache.set_without_expiry(&by_hash, &seeded).await.unwrap();
3877        let refilled = plugin.verify_api_key(&input, &ctx).await.unwrap();
3878        assert_eq!(refilled.remaining, Some(4.0));
3879        assert_eq!(refilled.request_count, Some(1.0));
3880        assert_ne!(refilled.last_refill_at.as_deref(), Some(previous.as_str()));
3881        let observed: alibi_core::ApiKey =
3882            serde_json::from_str(&cache.get(&by_id).await.unwrap().unwrap()).unwrap();
3883        assert_eq!(observed.remaining, Some(4.0));
3884        assert_eq!(observed.request_count, Some(1.0));
3885        assert_eq!(
3886            cache.get(&by_id).await.unwrap(),
3887            cache.get(&by_hash).await.unwrap()
3888        );
3889        assert!(
3890            ctx.database
3891                .list_api_keys_by_reference(&owner)
3892                .await
3893                .unwrap()
3894                .is_empty()
3895        );
3896    }
3897}
3898// LCOV_EXCL_STOP