|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#7826] `sys_session`'s ADR-0057 TTL sweep, driven end to end: the REAL |
| 4 | +// declaration (`@objectstack/platform-objects`) through the REAL Reaper |
| 5 | +// (`@objectstack/objectql` `LifecycleService`) against a REAL SQL backend |
| 6 | +// (`@objectstack/driver-sql`, live better-sqlite3), over a table this driver |
| 7 | +// created from that same declaration. |
| 8 | +// |
| 9 | +// ## Why this suite exists at all |
| 10 | +// |
| 11 | +// The declaration it exercises is |
| 12 | +// |
| 13 | +// ttl: { field: 'expires_at', expireAfter: '1d', |
| 14 | +// onlyWhen: { revoked_at: { $null: true } } } |
| 15 | +// |
| 16 | +// and the `onlyWhen` clause is the whole point. `reconcileSessionDelete` in |
| 17 | +// `session-tombstone.ts` (#7732 / ADR-0069 D4) BACKDATES `expires_at` to |
| 18 | +// `now - 1000` when it tombstones a revoked session, and clears nothing — so a |
| 19 | +// tombstone is a strict SUPERSET of an ordinary row that looks MAXIMALLY |
| 20 | +// expired. A TTL keyed on `expires_at` without the filter therefore reaps the |
| 21 | +// audit records FIRST AND HARDEST. That backdating is pinned independently in |
| 22 | +// `session-tombstone.test.ts`; here it is produced by that same function and |
| 23 | +// then fed to the sweep, so the row under test is the one production writes |
| 24 | +// rather than one this file imagined. |
| 25 | +// |
| 26 | +// ## The two controls, and why neither is sufficient alone |
| 27 | +// |
| 28 | +// * SPARING — the tombstone survives the sweep. |
| 29 | +// * POSITIVE — an ordinary expired row is deleted BY THE SAME SWEEP. |
| 30 | +// |
| 31 | +// Without the positive control the filter could be disabling the sweep |
| 32 | +// outright and the sparing control would still pass; without the sparing |
| 33 | +// control the sweep is just a sweep. They are made maximally discriminating by |
| 34 | +// giving both rows the IDENTICAL `expires_at`: the only property that differs |
| 35 | +// is `revoked_at`, so nothing but the filter can separate their fates. |
| 36 | +// |
| 37 | +// ⚠️ The honest before-state for the sparing control is NOT the pre-fix tree: |
| 38 | +// on `origin/main` `sys_session` declared no `lifecycle` at all, so there was |
| 39 | +// no sweep and a tombstone survived trivially. The control was proved to |
| 40 | +// discriminate by ABLATING the declaration itself — dropping `onlyWhen` while |
| 41 | +// keeping the `ttl`, rebuilding `@objectstack/platform-objects` (this package |
| 42 | +// resolves it through `exports`, i.e. `dist/`) and watching the tombstone get |
| 43 | +// reaped. See the PR body for that run. |
| 44 | + |
| 45 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
| 46 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 47 | +import { LifecycleService, assertEngineDeleteDispatch } from '@objectstack/objectql'; |
| 48 | +import type { LifecycleEngineLike, LifecycleObjectLike } from '@objectstack/objectql'; |
| 49 | +import type { DriverQuery } from '@objectstack/spec/contracts'; |
| 50 | +import { runWithEndpointContext } from '@better-auth/core/context'; |
| 51 | +import { SysSession } from '@objectstack/platform-objects/identity'; |
| 52 | +import { reconcileSessionDelete } from './session-tombstone'; |
| 53 | + |
| 54 | +/** The instant the revocation happens; the sweep runs two days later. */ |
| 55 | +const REVOKED_AT_MS = Date.parse('2026-08-01T00:00:00.000Z'); |
| 56 | +const SWEEP_AT_MS = REVOKED_AT_MS + 2 * 86_400_000; |
| 57 | + |
| 58 | +const openDrivers: SqlDriver[] = []; |
| 59 | +afterEach(async () => { |
| 60 | + while (openDrivers.length) { |
| 61 | + const d = openDrivers.pop(); |
| 62 | + try { await d?.disconnect(); } catch { /* noop */ } |
| 63 | + } |
| 64 | +}); |
| 65 | + |
| 66 | +const silentLogger = { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} }; |
| 67 | + |
| 68 | +/** |
| 69 | + * The tombstone patch as the REAL writer composes it — `reconcileSessionDelete` |
| 70 | + * under a real better-auth endpoint context for an interactive revoke. Only the |
| 71 | + * `update` surface is needed: the function answers the delete by writing this |
| 72 | + * patch instead of deleting. |
| 73 | + */ |
| 74 | +async function realTombstonePatch(atMs = REVOKED_AT_MS): Promise<Record<string, any>> { |
| 75 | + const patches: Array<Record<string, any>> = []; |
| 76 | + const engine = { update: async (_o: string, p: any) => { patches.push(p); } }; |
| 77 | + // The writer stamps from `Date.now()`. Pinning the clock to the simulated |
| 78 | + // revocation instant is what puts the row on the same timeline as the sweep, |
| 79 | + // WITHOUT rebasing (and so possibly flattening) the backdating this suite is |
| 80 | + // about — the offset is still the one the real function chose. |
| 81 | + vi.useFakeTimers(); |
| 82 | + vi.setSystemTime(new Date(atMs)); |
| 83 | + try { |
| 84 | + const proceed = await runWithEndpointContext( |
| 85 | + { path: '/revoke-session', context: {} } as any, |
| 86 | + () => reconcileSessionDelete(engine as any, 'sys_session', { id: 'sess_tombstone', revoked_at: null }), |
| 87 | + ); |
| 88 | + expect(proceed).toBe(false); // answered by a tombstone, not a delete |
| 89 | + } finally { |
| 90 | + vi.useRealTimers(); |
| 91 | + } |
| 92 | + expect(patches).toHaveLength(1); |
| 93 | + return patches[0]; |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * `LifecycleEngineLike` over a live `SqlDriver`. `delete` opens with ObjectQL's |
| 98 | + * own dispatch predicate so this double refuses exactly what the real engine |
| 99 | + * refuses (#4550) rather than re-deriving the rule. |
| 100 | + */ |
| 101 | +function sweepEngine(driver: SqlDriver, objects: LifecycleObjectLike[]): LifecycleEngineLike { |
| 102 | + return { |
| 103 | + registry: { getAllObjects: () => objects }, |
| 104 | + getDriverForObject: () => driver, |
| 105 | + async find(object: string, options: any) { |
| 106 | + // Typed rather than erased to `any`: the driver silently DROPS an |
| 107 | + // unrecognised query key, so `tsc` is the only channel that can reject a |
| 108 | + // misspelt one here (#4918). |
| 109 | + const query: DriverQuery = { where: options?.where, limit: options?.limit }; |
| 110 | + return driver.find(object, query); |
| 111 | + }, |
| 112 | + async delete(object: string, options: any) { |
| 113 | + const dispatch = assertEngineDeleteDispatch(options); |
| 114 | + if (dispatch.kind === 'by-id') { |
| 115 | + // `EngineDeleteDispatch.id` admits `bigint`; the driver's by-id delete |
| 116 | + // takes `string | number`. Narrowed by stringifying — the same reason |
| 117 | + // `LifecycleService`'s own `idKey` stringifies — rather than cast away, |
| 118 | + // which is what hid the mismatch here in the first place. |
| 119 | + const id = typeof dispatch.id === 'bigint' ? dispatch.id.toString() : dispatch.id; |
| 120 | + return (await driver.delete(object, id)) ? 1 : 0; |
| 121 | + } |
| 122 | + const query: DriverQuery = { where: options?.where }; |
| 123 | + return driver.deleteMany(object, query); |
| 124 | + }, |
| 125 | + }; |
| 126 | +} |
| 127 | + |
| 128 | +/** |
| 129 | + * Live `sys_session` table, created by the driver from the REAL object |
| 130 | + * declaration, seeded with the three rows the policy has to tell apart. |
| 131 | + * |
| 132 | + * `lifecycle` is the declaration under test unless `override` replaces it — |
| 133 | + * that parameter is what lets the ablation be expressed as a case in this file |
| 134 | + * as well as being run for real against a rebuilt `dist/` (see the header). |
| 135 | + */ |
| 136 | +async function seeded(override?: any) { |
| 137 | + const driver = new SqlDriver({ |
| 138 | + client: 'better-sqlite3', |
| 139 | + connection: { filename: ':memory:' }, |
| 140 | + useNullAsDefault: true, |
| 141 | + }); |
| 142 | + openDrivers.push(driver); |
| 143 | + await driver.initObjects([SysSession as any]); |
| 144 | + |
| 145 | + const patch = await realTombstonePatch(); |
| 146 | + const tombstoneExpiry = new Date(patch.expires_at).toISOString(); |
| 147 | + |
| 148 | + await driver.create('sys_session', { |
| 149 | + id: 'sess_tombstone', |
| 150 | + user_id: 'usr_1', |
| 151 | + token: 'tok_tombstone', |
| 152 | + // Exactly what the real tombstone writer produced. |
| 153 | + expires_at: tombstoneExpiry, |
| 154 | + revoked_at: new Date(patch.revoked_at).toISOString(), |
| 155 | + revoke_reason: patch.revoke_reason, |
| 156 | + }); |
| 157 | + await driver.create('sys_session', { |
| 158 | + id: 'sess_expired', |
| 159 | + user_id: 'usr_1', |
| 160 | + token: 'tok_expired', |
| 161 | + // IDENTICAL expiry to the tombstone — `revoked_at` is the only difference. |
| 162 | + expires_at: tombstoneExpiry, |
| 163 | + revoked_at: null, |
| 164 | + }); |
| 165 | + await driver.create('sys_session', { |
| 166 | + id: 'sess_live', |
| 167 | + user_id: 'usr_1', |
| 168 | + token: 'tok_live', |
| 169 | + expires_at: new Date(SWEEP_AT_MS + 7 * 86_400_000).toISOString(), |
| 170 | + revoked_at: null, |
| 171 | + }); |
| 172 | + |
| 173 | + const object: LifecycleObjectLike = { |
| 174 | + name: SysSession.name, |
| 175 | + lifecycle: (override === undefined ? (SysSession as any).lifecycle : override), |
| 176 | + fields: SysSession.fields as any, |
| 177 | + }; |
| 178 | + const service = new LifecycleService({ |
| 179 | + getEngine: () => sweepEngine(driver, [object]), |
| 180 | + logger: silentLogger, |
| 181 | + now: () => SWEEP_AT_MS, |
| 182 | + initialDelayMs: 1, |
| 183 | + sweepIntervalMs: 10, |
| 184 | + } as any); |
| 185 | + |
| 186 | + return { driver, service, patch, tombstoneExpiry }; |
| 187 | +} |
| 188 | + |
| 189 | +const ALL_ROWS: DriverQuery = {}; |
| 190 | +const survivors = async (driver: SqlDriver) => |
| 191 | + (await driver.find('sys_session', ALL_ROWS)).map((r: any) => r.id).sort(); |
| 192 | + |
| 193 | +describe('[#7826] sys_session TTL sweep — real declaration, real Reaper, live SQL', () => { |
| 194 | + it('the hazard is real: the tombstone writer backdates expires_at below the revocation instant', async () => { |
| 195 | + const patch = await realTombstonePatch(); |
| 196 | + expect(patch.revoked_at).toBeInstanceOf(Date); |
| 197 | + expect(patch.revoke_reason).toBeTruthy(); |
| 198 | + // The defining property: the tombstone looks MORE expired than a session |
| 199 | + // that merely lapsed, which is why a naive TTL reaps tombstones first. |
| 200 | + expect(new Date(patch.expires_at).getTime()).toBeLessThan(new Date(patch.revoked_at).getTime()); |
| 201 | + }); |
| 202 | + |
| 203 | + it('SPARING CONTROL — the revoked tombstone survives the sweep', async () => { |
| 204 | + const { driver, service } = await seeded(); |
| 205 | + |
| 206 | + const report = await service.sweep(); |
| 207 | + |
| 208 | + expect(await survivors(driver)).toContain('sess_tombstone'); |
| 209 | + const tombstoneById: DriverQuery = { where: { id: 'sess_tombstone' } }; |
| 210 | + const row: any = await driver.findOne('sys_session', tombstoneById); |
| 211 | + expect(row).toBeTruthy(); |
| 212 | + expect(row.revoke_reason).toBeTruthy(); // the audit content is intact |
| 213 | + expect(report.errors).toEqual([]); |
| 214 | + }); |
| 215 | + |
| 216 | + it('POSITIVE CONTROL — an ordinary expired session IS deleted by that same sweep', async () => { |
| 217 | + const { driver, service } = await seeded(); |
| 218 | + |
| 219 | + const report = await service.sweep(); |
| 220 | + |
| 221 | + // One sweep, three rows, two verdicts: the expired row is gone, the |
| 222 | + // tombstone and the live session remain. |
| 223 | + expect(await survivors(driver)).toEqual(['sess_live', 'sess_tombstone']); |
| 224 | + const ttl = report.swept.find((s: any) => s.object === 'sys_session' && s.policy === 'ttl'); |
| 225 | + expect(ttl).toBeTruthy(); |
| 226 | + expect(ttl!.deleted).toBe(1); |
| 227 | + }); |
| 228 | + |
| 229 | + it('ABLATION — without `onlyWhen` the same sweep reaps the tombstone too', async () => { |
| 230 | + // The declaration minus its filter: the naive policy #10165 existed to |
| 231 | + // make avoidable. This is the case the sparing control has to discriminate |
| 232 | + // against, so the control is not vacuous. |
| 233 | + const { driver, service } = await seeded({ |
| 234 | + class: 'transient', |
| 235 | + ttl: { field: 'expires_at', expireAfter: '1d' }, |
| 236 | + }); |
| 237 | + |
| 238 | + await service.sweep(); |
| 239 | + |
| 240 | + expect(await survivors(driver)).toEqual(['sess_live']); |
| 241 | + }); |
| 242 | +}); |
0 commit comments