Skip to main content

codlet_core/state/
token.rs

1//! Form-token consume state machine (RFC-007).
2//!
3//! This is a direct port of `zinnias_ciao_contracts::auth::classify_token_consume`
4//! and its six tests, lifted into codlet-core so the logic is a pure,
5//! storage-free primitive. The function signature and all invariants are
6//! preserved exactly; adapters supply the inputs from their query results.
7
8/// Outcome of a single-use form-token consume attempt (RFC-007 §3).
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum TokenConsumeOutcome {
11    /// This call won the atomic race (UPDATE changed exactly one row). Execute
12    /// the operation.
13    Proceed,
14    /// Token already consumed — idempotent replay. Return the prior result
15    /// reference if one was stored; do not re-execute the operation.
16    Replay,
17    /// Token not found, expired, or binding mismatch. Reject the request.
18    Invalid,
19}
20
21/// Classify a consume attempt from the atomic UPDATE and a follow-up SELECT.
22///
23/// `changed` is the affected-row count from:
24///
25/// ```sql
26/// UPDATE codlet_form_tokens
27/// SET consumed_at = :now
28/// WHERE lookup_key = :key
29///   AND purpose    = :purpose
30///   AND subject    = :subject         -- binding
31///   AND expires_at > :now
32///   AND consumed_at IS NULL
33/// ```
34///
35/// When `changed == 0`, the follow-up SELECT provides:
36///
37/// - `found`           — a row matching the lookup key + purpose + subject exists.
38/// - `already_consumed`— that row has `consumed_at IS NOT NULL`.
39/// - `binding_ok`      — the row's bound resource matches the caller's.
40///
41/// The single rule that must never be violated (INV-6): **`changed == 0` never
42/// produces [`TokenConsumeOutcome::Proceed`]** (RFC-007 §5, §13.5,
43/// acceptance checklist item "changed == 0 never proceeds").
44#[must_use]
45pub fn classify_token_consume(
46    changed: usize,
47    found: bool,
48    already_consumed: bool,
49    binding_ok: bool,
50) -> TokenConsumeOutcome {
51    if changed == 1 {
52        return TokenConsumeOutcome::Proceed;
53    }
54    // changed == 0: the conditional UPDATE matched nothing — classify why.
55    if !found || !binding_ok {
56        return TokenConsumeOutcome::Invalid;
57    }
58    if already_consumed {
59        return TokenConsumeOutcome::Replay;
60    }
61    // Row exists, unconsumed, binding ok, but UPDATE still missed →
62    // the expiry guard fired. Treat as invalid.
63    TokenConsumeOutcome::Invalid
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    // The six tests from zinnias-ciao contracts/src/auth.rs, preserved verbatim
71    // as a compatibility / regression suite.
72
73    #[test]
74    fn consume_winner_proceeds() {
75        assert_eq!(
76            classify_token_consume(1, true, false, true),
77            TokenConsumeOutcome::Proceed
78        );
79    }
80
81    #[test]
82    fn consume_loser_of_race_sees_replay() {
83        // Concurrent double-submit: second call's UPDATE changes 0 rows because
84        // consumed_at is set. Must replay, not re-execute.
85        assert_eq!(
86            classify_token_consume(0, true, true, true),
87            TokenConsumeOutcome::Replay
88        );
89    }
90
91    #[test]
92    fn consume_unknown_token_is_invalid() {
93        assert_eq!(
94            classify_token_consume(0, false, false, false),
95            TokenConsumeOutcome::Invalid
96        );
97    }
98
99    #[test]
100    fn consume_binding_mismatch_is_invalid() {
101        // Right token, wrong bound_resource → rejection, not replay.
102        assert_eq!(
103            classify_token_consume(0, true, false, false),
104            TokenConsumeOutcome::Invalid
105        );
106    }
107
108    #[test]
109    fn consume_expired_unconsumed_is_invalid() {
110        // Found, unconsumed, binding ok, but UPDATE missed (expiry guard).
111        assert_eq!(
112            classify_token_consume(0, true, false, true),
113            TokenConsumeOutcome::Invalid
114        );
115    }
116
117    #[test]
118    fn consume_never_double_proceeds() {
119        // Exhaustive: across all changed==0 states, Proceed is impossible.
120        // Acceptance checklist RFC-007 §13.5: "changed == 0 never proceeds".
121        for found in [false, true] {
122            for consumed in [false, true] {
123                for binding in [false, true] {
124                    assert_ne!(
125                        classify_token_consume(0, found, consumed, binding),
126                        TokenConsumeOutcome::Proceed,
127                        "changed==0 must never proceed \
128                         (found={found} consumed={consumed} binding={binding})"
129                    );
130                }
131            }
132        }
133    }
134
135    // Additional codlet-specific tests.
136
137    #[test]
138    fn changed_greater_than_one_is_conservatively_invalid() {
139        // >1 is a storage invariant violation. We must never Proceed.
140        for bad in [2usize, 100] {
141            assert_ne!(
142                classify_token_consume(bad, true, false, true),
143                TokenConsumeOutcome::Proceed,
144                "changed={bad} must not Proceed"
145            );
146        }
147    }
148
149    #[test]
150    fn not_found_always_invalid_regardless_of_other_flags() {
151        for consumed in [false, true] {
152            for binding in [false, true] {
153                assert_eq!(
154                    classify_token_consume(0, false, consumed, binding),
155                    TokenConsumeOutcome::Invalid,
156                    "not found must always be Invalid"
157                );
158            }
159        }
160    }
161}