codlet-sqlx 0.14.2

SQLite and PostgreSQL storage adapters for codlet, backed by SQLx (RFC-011, RFC-034).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! Full conformance suite for the SQLite adapter (RFC-023, RFC-022).
//!
//! Each test function wires up a fresh SQLite in-memory pool, runs migrations,
//! and passes the pool to the `codlet-conformance` parameterised runner. The
//! concurrent-claim race test verifies the single-winner guarantee under real
//! async task concurrency (RFC-022). The admin tests verify RFC-030.

#[cfg(feature = "sqlite")]
mod sqlite_tests {
    use codlet_conformance::fixtures::{LATER, NOW, code_lk, code_record};
    use codlet_core::admin::{CodeAdminStore, CodeListFilter};
    use codlet_core::secret::{CodeId, ScopeKey};
    use codlet_core::store::code::{ClaimRequest, CodeStore};
    use codlet_sqlx::{SqliteStore, run_migrations};

    async fn fresh_store() -> SqliteStore {
        // Each test gets its own in-memory database. We use a single-connection
        // pool so all queries share the same SQLite in-memory database — multiple
        // connections would each get an independent (empty) database.
        let pool = sqlx::sqlite::SqlitePoolOptions::new()
            .max_connections(1)
            .connect("sqlite::memory:")
            .await
            .unwrap();
        run_migrations(&pool).await.unwrap();
        SqliteStore::new(pool)
    }

    // ── Code store ────────────────────────────────────────────────────────────────

    #[tokio::test]
    async fn sqlite_code_store_conformance() {
        codlet_conformance::run_code_store_conformance(fresh_store).await;
    }

    // ── Session store ─────────────────────────────────────────────────────────────

    #[tokio::test]
    async fn sqlite_session_store_conformance() {
        codlet_conformance::run_session_store_conformance(fresh_store).await;
    }

    // ── Form-token store ──────────────────────────────────────────────────────────

    #[tokio::test]
    async fn sqlite_form_token_store_conformance() {
        codlet_conformance::run_form_token_store_conformance(fresh_store).await;
    }

    // ── Migration smoke tests ─────────────────────────────────────────────────────

    #[tokio::test]
    async fn migrations_are_idempotent() {
        let pool = sqlx::sqlite::SqlitePoolOptions::new()
            .max_connections(1)
            .connect("sqlite::memory:")
            .await
            .unwrap();
        // Running twice must not error (IF NOT EXISTS semantics).
        run_migrations(&pool).await.unwrap();
        run_migrations(&pool).await.unwrap();
    }

    #[tokio::test]
    async fn schema_includes_key_version_columns() {
        // RFC-011 §10.5: "Schema includes key version columns from first migration."
        let pool = sqlx::sqlite::SqlitePoolOptions::new()
            .max_connections(1)
            .connect("sqlite::memory:")
            .await
            .unwrap();
        run_migrations(&pool).await.unwrap();

        for table in ["codlet_codes", "codlet_sessions", "codlet_form_tokens"] {
            let result: Result<Vec<(String,)>, _> =
                sqlx::query_as(&format!("SELECT key_version FROM {table} LIMIT 0"))
                    .fetch_all(&pool)
                    .await;
            assert!(
                result.is_ok(),
                "table {table} must have a key_version column"
            );
        }
    }

    // ── CodeAdminStore tests (RFC-030) ────────────────────────────────────────────

    #[tokio::test]
    async fn admin_list_codes_all() {
        let store = fresh_store().await;
        store
            .insert_code(code_record("c1", "sec1", LATER, None))
            .await
            .unwrap();
        store
            .insert_code(code_record("c2", "sec2", LATER, Some("scope-A")))
            .await
            .unwrap();

        let rows = store.list_codes(&CodeListFilter::all(), NOW).await.unwrap();
        assert_eq!(rows.len(), 2, "all filter must return both codes");
        for row in &rows {
            let dbg = format!("{row:?}");
            assert!(
                !dbg.contains("lookup_key"),
                "admin meta must not expose lookup_key"
            );
        }
    }

    #[tokio::test]
    async fn admin_list_codes_active_only() {
        let store = fresh_store().await;
        store
            .insert_code(code_record("ca1", "seca1", LATER, None))
            .await
            .unwrap();
        store
            .insert_code(code_record("ca2", "seca2", LATER, None))
            .await
            .unwrap();
        // Claim one.
        let found = store
            .find_redeemable(&[code_lk("seca1")], NOW, None)
            .await
            .unwrap()
            .unwrap();
        store
            .claim_code(&ClaimRequest {
                code_id: &found.id,
                subject: &codlet_core::secret::SubjectId::new("u1".into()),
                now: NOW,
                purpose: None,
                scope: None,
            })
            .await
            .unwrap();

        let active = store
            .list_codes(
                &CodeListFilter {
                    active_only: true,
                    ..Default::default()
                },
                NOW,
            )
            .await
            .unwrap();
        assert_eq!(active.len(), 1);
        assert_eq!(active[0].id, CodeId::new("ca2".into()));
    }

    #[tokio::test]
    async fn admin_list_codes_scoped() {
        let store = fresh_store().await;
        store
            .insert_code(code_record("cs1", "secs1", LATER, Some("scope-X")))
            .await
            .unwrap();
        store
            .insert_code(code_record("cs2", "secs2", LATER, Some("scope-Y")))
            .await
            .unwrap();
        store
            .insert_code(code_record("cs3", "secs3", LATER, None))
            .await
            .unwrap();

        let scoped = store
            .list_codes(
                &CodeListFilter::active_in_scope(ScopeKey::new("scope-X")),
                NOW,
            )
            .await
            .unwrap();
        assert_eq!(scoped.len(), 1);
        assert_eq!(scoped[0].id, CodeId::new("cs1".into()));
        assert_eq!(scoped[0].scope.as_deref(), Some("scope-X"));
    }

    #[tokio::test]
    async fn admin_list_codes_limit() {
        let store = fresh_store().await;
        for i in 0..5u8 {
            store
                .insert_code(code_record(
                    &format!("cl{i}"),
                    &format!("secl{i}"),
                    LATER,
                    None,
                ))
                .await
                .unwrap();
        }
        let limited = store
            .list_codes(
                &CodeListFilter {
                    limit: Some(2),
                    ..Default::default()
                },
                NOW,
            )
            .await
            .unwrap();
        assert_eq!(limited.len(), 2);
    }

    #[tokio::test]
    async fn admin_get_code_meta_found() {
        let store = fresh_store().await;
        store
            .insert_code(code_record("cm1", "secm1", LATER, Some("scope-Z")))
            .await
            .unwrap();

        let meta = store
            .get_code_meta(&CodeId::new("cm1".into()))
            .await
            .unwrap()
            .expect("must find the inserted code");
        assert_eq!(meta.id, CodeId::new("cm1".into()));
        assert_eq!(meta.scope.as_deref(), Some("scope-Z"));
        assert_eq!(meta.grant.as_deref(), Some("grant-cm1"));
        assert!(meta.used_at.is_none());
        assert!(meta.revoked_at.is_none());
        assert!(meta.created_at.is_some(), "created_at must be stored");
    }

    #[tokio::test]
    async fn admin_get_code_meta_not_found() {
        let store = fresh_store().await;
        let meta = store
            .get_code_meta(&CodeId::new("ghost".into()))
            .await
            .unwrap();
        assert!(meta.is_none());
    }

    #[tokio::test]
    async fn admin_meta_shows_used_state_after_claim() {
        let store = fresh_store().await;
        store
            .insert_code(code_record("cu1", "secu1", LATER, None))
            .await
            .unwrap();
        let found = store
            .find_redeemable(&[code_lk("secu1")], NOW, None)
            .await
            .unwrap()
            .unwrap();
        store
            .claim_code(&ClaimRequest {
                code_id: &found.id,
                subject: &codlet_core::secret::SubjectId::new("alice".into()),
                now: NOW,
                purpose: None,
                scope: None,
            })
            .await
            .unwrap();

        let meta = store
            .get_code_meta(&CodeId::new("cu1".into()))
            .await
            .unwrap()
            .unwrap();
        assert!(meta.used_at.is_some(), "used_at must be set after claim");
        assert_eq!(meta.used_by.as_ref().map(|s| s.as_str()), Some("alice"));
        assert!(!meta.is_redeemable_at(NOW));
    }

    #[tokio::test]
    async fn admin_meta_contains_no_lookup_key() {
        // RFC-030 acceptance: "Listing APIs cannot return plaintext secrets."
        let store = fresh_store().await;
        store
            .insert_code(code_record("cn1", "topsecretsecn1", LATER, None))
            .await
            .unwrap();

        let meta = store
            .get_code_meta(&CodeId::new("cn1".into()))
            .await
            .unwrap()
            .unwrap();
        let dbg = format!("{meta:?}");
        for forbidden in ["lookup_key", "topsecretsecn1", "hmac"] {
            assert!(
                !dbg.contains(forbidden),
                "CodeMeta debug must not contain {forbidden:?}: {dbg}"
            );
        }
    }
}

// ── PostgreSQL conformance tests (RFC-034) ─────────────────────────────────
//
// Require: --features postgres-test AND Docker available.
// Run:     cargo test -p codlet-sqlx --features postgres-test
// CI:      see .github/workflows/ci.yml  test-postgres job

#[cfg(feature = "postgres-test")]
mod postgres_tests {
    use codlet_conformance::fixtures::{LATER, NOW, code_lk, code_record};
    use codlet_core::admin::{CodeAdminStore, CodeListFilter};
    use codlet_core::secret::{CodeId, ScopeKey};
    use codlet_core::store::code::{ClaimRequest, CodeStore};
    use codlet_sqlx::{PostgresStore, run_postgres_migrations};
    use testcontainers_modules::{postgres::Postgres, testcontainers::runners::AsyncRunner};

    async fn fresh_pg_store() -> PostgresStore {
        // Spin up a real PostgreSQL container for each test group.
        // testcontainers drops the container when the returned handle is dropped.
        let container = Postgres::default().start().await.unwrap();
        let url = format!(
            "postgres://postgres:postgres@127.0.0.1:{}/postgres",
            container.get_host_port_ipv4(5432).await.unwrap()
        );
        let pool = sqlx::PgPool::connect(&url).await.unwrap();
        run_postgres_migrations(&pool).await.unwrap();
        // Leak the container handle so it lives for the duration of the test.
        std::mem::forget(container);
        PostgresStore::new(pool)
    }

    // ── Conformance suite ─────────────────────────────────────────────────

    #[tokio::test]
    async fn postgres_code_store_conformance() {
        codlet_conformance::run_code_store_conformance(fresh_pg_store).await;
    }

    #[tokio::test]
    async fn postgres_session_store_conformance() {
        codlet_conformance::run_session_store_conformance(fresh_pg_store).await;
    }

    #[tokio::test]
    async fn postgres_form_token_store_conformance() {
        codlet_conformance::run_form_token_store_conformance(fresh_pg_store).await;
    }

    // ── Migration idempotency ─────────────────────────────────────────────

    #[tokio::test]
    async fn postgres_migrations_are_idempotent() {
        let container = Postgres::default().start().await.unwrap();
        let url = format!(
            "postgres://postgres:postgres@127.0.0.1:{}/postgres",
            container.get_host_port_ipv4(5432).await.unwrap()
        );
        let pool = sqlx::PgPool::connect(&url).await.unwrap();
        run_postgres_migrations(&pool).await.unwrap();
        // Second run must succeed (IF NOT EXISTS).
        run_postgres_migrations(&pool).await.unwrap();
        std::mem::forget(container);
    }

    #[tokio::test]
    async fn postgres_schema_uses_bigint_timestamps() {
        // RFC-034 §6: timestamps must be BIGINT, not INTEGER.
        let container = Postgres::default().start().await.unwrap();
        let url = format!(
            "postgres://postgres:postgres@127.0.0.1:{}/postgres",
            container.get_host_port_ipv4(5432).await.unwrap()
        );
        let pool = sqlx::PgPool::connect(&url).await.unwrap();
        run_postgres_migrations(&pool).await.unwrap();

        let row: (String,) = sqlx::query_as(
            "SELECT data_type FROM information_schema.columns
             WHERE table_name = 'codlet_codes' AND column_name = 'expires_at'",
        )
        .fetch_one(&pool)
        .await
        .unwrap();
        assert_eq!(row.0, "bigint", "expires_at must be BIGINT");
        std::mem::forget(container);
    }

    // ── CodeAdminStore ────────────────────────────────────────────────────

    #[tokio::test]
    async fn postgres_admin_list_and_get() {
        let store = fresh_pg_store().await;
        store
            .insert_code(code_record("pg1", "secpg1", LATER, None))
            .await
            .unwrap();
        store
            .insert_code(code_record("pg2", "secpg2", LATER, Some("scope-P")))
            .await
            .unwrap();

        let all = store.list_codes(&CodeListFilter::all(), NOW).await.unwrap();
        assert_eq!(all.len(), 2);

        let scoped = store
            .list_codes(
                &CodeListFilter::active_in_scope(ScopeKey::new("scope-P")),
                NOW,
            )
            .await
            .unwrap();
        assert_eq!(scoped.len(), 1);
        assert_eq!(scoped[0].id, CodeId::new("pg2".into()));

        let meta = store
            .get_code_meta(&CodeId::new("pg1".into()))
            .await
            .unwrap()
            .expect("must exist");
        assert!(meta.created_at.is_some());
        assert!(meta.used_at.is_none());
    }

    // ── No RETURNING, no FOR UPDATE ───────────────────────────────────────

    #[tokio::test]
    async fn postgres_claim_uses_rows_affected_not_returning() {
        // RFC-034 §7: no RETURNING. The claim result is derived solely
        // from rows_affected() == classify_claim().
        // This test verifies the contract indirectly: a won claim produces
        // exactly one affected row, observable via the admin store.
        let store = fresh_pg_store().await;
        store
            .insert_code(code_record("pgclaim", "secpgclaim", LATER, None))
            .await
            .unwrap();
        let found = store
            .find_redeemable(&[code_lk("secpgclaim")], NOW, None)
            .await
            .unwrap()
            .unwrap();
        let outcome = store
            .claim_code(&ClaimRequest {
                code_id: &found.id,
                subject: &codlet_core::secret::SubjectId::new("alice".into()),
                now: NOW,
                purpose: None,
                scope: None,
            })
            .await
            .unwrap();
        assert!(
            matches!(outcome, codlet_core::state::ClaimOutcome::Won),
            "expected Won, got {outcome:?}"
        );
        let meta = store
            .get_code_meta(&CodeId::new("pgclaim".into()))
            .await
            .unwrap()
            .unwrap();
        assert!(meta.used_at.is_some());
        assert_eq!(meta.used_by.as_ref().map(|s| s.as_str()), Some("alice"));
    }
}