fix(sandbox): repair the silently-failing nightly sandbox cleanup and lock down its RPCs (#1449)
* fix(sandbox): repair the silently-failing nightly sandbox cleanup and lock down its RPCs
The daily cleanup cron has deleted nothing for months: cleanup_sandbox_user
died on the journal-line immutability trigger for every user (the seed posts
vouchers since spring), and cleanup_expired_sandbox_users swallowed each
failure as a WARNING while reporting success. 658 expired sandbox users plus
21 orphaned anonymous users had accumulated in prod auth.users.
- cleanup_sandbox_user sets the sanctioned gnubok.allow_delete flag plus a
new transaction-local gnubok.sandbox_cleanup flag, only after verifying
is_sandbox; write_audit_log, audit_log_immutable (DELETE only, per-row
sandbox re-check), enforce_dimension_registry_guards (DELETE only) and
enforce_pending_operations_no_delete (DELETE only) respect it
- clears salary_runs voucher-link FKs and purges the sandbox company's
audit rows before the auth.users cascade
- cleanup_expired_sandbox_users returns {cleaned, failed, orphans_removed},
additionally sweeps expired anonymous users that never got a
company_settings row, and takes an optional p_limit for bounded batches;
the cron route logs failures at error level and accepts both return shapes
- both RPCs lose their default PUBLIC EXECUTE grant (anon and authenticated
could call them via PostgREST) and are now service_role-only
- validated by replaying the full delete chain against prod inside aborted
transactions (21 users sampled across all seed eras, zero failures) and a
committed staging run; pg-real suite + cron route unit tests added
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): per-row sandbox re-verification in teardown guards, is_anonymous column guard
Resolution pass for PR #1449 review findings and the pg-real CI failure:
- Swedish accounting review: enforce_dimension_registry_guards and
enforce_pending_operations_no_delete now re-verify per row that
OLD.company_id belongs to a sandbox company (same pattern as
audit_log_immutable) instead of trusting the gnubok.sandbox_cleanup flag
alone. Because that re-check needs company_settings to still exist,
cleanup_sandbox_user deletes pending_operations and dimensions explicitly
before the auth.users cascade.
- pg-real CI: auth.users.is_anonymous does not exist in the CI
supabase/postgres image (or on older self-hosted stacks); the orphan sweep
in cleanup_expired_sandbox_users is now guarded on the column's existence,
and the pg test skips the orphan assertions on such stacks.
Re-validated on staging end-to-end: {cleaned: 5, failed: 0,
orphans_removed: 1}, fresh users and non-sandbox rows untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): make company_settings.is_sandbox write-once, prove orphan sweep fails loudly
Round-2 review findings (Swedish accounting review on PR #1449):
- Every teardown bypass trusts company_settings.is_sandbox, and RLS lets an
owner update their own settings row via PostgREST, so a real company that
flipped the flag would become eligible for full deletion by the nightly
cron. New trigger makes the flag write-once (no application path updates
it; a future sandbox-to-real conversion would ship its own migration).
- New pg test pins the reviewer's remaining concern: an anonymous user who
somehow has bookkeeping but no company_settings row is NOT silently
deleted by the orphan sweep; the unbypasseed immutability triggers make
the deletion fail loudly into the summary's failed count.
Validated on staging: flip blocked in both directions, unrelated
company_settings updates unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): guard is_sandbox provenance at INSERT, make orphan sweep exclusions explicit
Round-3 review hardening, approved by Emil:
- is_sandbox = true can now only be created by an anonymous-user JWT (the
sandbox seed's actor), service_role, or a direct database session. A
regular authenticated user could previously insert their settings row
pre-flagged and have the nightly cron destroy their real books, which
BFL 7 kap. forbids even self-inflicted. Claims are read from the
request.jwt.* GUCs directly so the check behaves identically on hosted,
self-hosted, and the CI auth shim.
- The orphan sweep now explicitly excludes anonymous users attached to any
companies or company_members row, instead of relying on downstream
immutability triggers throwing (emergent safety) to protect half-seeded
users.
- pg tests updated accordingly: blocked/allowed provenance paths, and the
half-seeded user is proven unreachable rather than merely failing loudly.
Validated on staging: authed insert blocked, anonymous-claim insert
allowed, half-seeded user untouched, sweep summary failed=0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): all-rows sandbox check, cleared bypass flags, tighter insert guard
CodeRabbit review pass on PR #1449 (its first non-rate-limited run):
- cleanup_sandbox_user now requires EVERY company_settings row of the user
to be sandbox-flagged, not an arbitrary single row: a hypothetical
mixed-company user would otherwise have their real company's rows reached
by the user-scoped deletes.
- Both bypass flags are cleared before the RPC returns, so later work in
the same transaction (the expired loop's next iterations, the orphan
sweep) never runs with them still armed.
- The is_sandbox insert guard now treats ANY PostgREST claims context
(claims json without a role claim included) as guarded, instead of
falling open when the role claim is absent.
- The flag-leak pg test now runs inside an explicit transaction (the old
version could not observe transaction-local GUCs at all), and a new test
covers the mixed sandbox/real user refusal.
Declined: replacing the em dashes inside the two replicated Swedish
exception messages; they are byte-identical copies of the strings already
deployed by migration 20260702084500 and changing them would alter live
user-facing errors out of scope.
Validated on staging: mixed user refused, flags cleared post-teardown,
role-less claims blocked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6458180bf5
commit
f7f3a31f8e
@@ -0,0 +1,376 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getClient, getPool } from './setup'
|
||||
import { insertPostedJournalEntry, seedCompany } from './fixtures'
|
||||
|
||||
/**
|
||||
* Sandbox cleanup RPCs (migration 20260807130000):
|
||||
*
|
||||
* The nightly cron was a silent no-op for months: cleanup_sandbox_user
|
||||
* deleted journal_entry_lines without setting the gnubok.allow_delete
|
||||
* bypass, so the BFL immutability trigger rejected the delete and the outer
|
||||
* loop swallowed the error as a WARNING. These tests pin the fixed behavior:
|
||||
* a sandbox company with posted vouchers and a booked salary run actually
|
||||
* deletes, non-sandbox users stay refused, immutability outside the RPC is
|
||||
* untouched, and the expired sweep also removes orphaned anonymous users
|
||||
* that never got a company_settings row.
|
||||
*/
|
||||
|
||||
async function seedSandboxUser(settingsCreatedAt?: string): Promise<{
|
||||
userId: string
|
||||
companyId: string
|
||||
entryId: string
|
||||
}> {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, is_sandbox, created_at)
|
||||
VALUES ($1, $2, true, COALESCE($3::timestamptz, now()))`,
|
||||
[userId, companyId, settingsCreatedAt ?? null],
|
||||
)
|
||||
const entryId = await insertPostedJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
})
|
||||
// The seed links a booked salary run to its vouchers with plain NO ACTION
|
||||
// FKs; recreate that so the test fails if the RPC forgets to clear them.
|
||||
await getPool().query(
|
||||
`INSERT INTO public.salary_runs
|
||||
(company_id, user_id, period_year, period_month, payment_date, salary_entry_id)
|
||||
VALUES ($1, $2, 2026, 1, '2026-01-25', $3)`,
|
||||
[companyId, userId, entryId],
|
||||
)
|
||||
// System dimensions (undeletable outside teardown) and a terminal-state
|
||||
// pending operation (delete-protected per BFL 7 kap.): both exist in every
|
||||
// modern sandbox and both blocked the auth.users cascade before the
|
||||
// gnubok.sandbox_cleanup bypass.
|
||||
await getPool().query(`SELECT public.ensure_company_dimensions($1)`, [companyId])
|
||||
await getPool().query(
|
||||
`INSERT INTO public.pending_operations
|
||||
(user_id, company_id, operation_type, title, status)
|
||||
VALUES ($1, $2, 'categorize_transaction', 'Sandbox cleanup test op', 'rejected')`,
|
||||
[userId, companyId],
|
||||
)
|
||||
return { userId, companyId, entryId }
|
||||
}
|
||||
|
||||
async function insertAnonymousAuthUser(createdAt: string): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO auth.users (id, email, instance_id, is_anonymous, created_at)
|
||||
VALUES ($1, NULL, '00000000-0000-0000-0000-000000000000'::uuid, true, $2::timestamptz)`,
|
||||
[id, createdAt],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
// auth.users.is_anonymous arrived with GoTrue anonymous sign-ins; the CI
|
||||
// supabase/postgres image predates it. The RPC skips the orphan sweep on such
|
||||
// stacks, so the test skips the matching assertions rather than fabricating a
|
||||
// schema hosted Supabase would not have.
|
||||
async function hasIsAnonymousColumn(): Promise<boolean> {
|
||||
const { rows } = await getPool().query<{ has: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'auth' AND table_name = 'users'
|
||||
AND column_name = 'is_anonymous'
|
||||
) AS has`,
|
||||
)
|
||||
return rows[0]!.has
|
||||
}
|
||||
|
||||
async function authUserExists(id: string): Promise<boolean> {
|
||||
const { rows } = await getPool().query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM auth.users WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
return rows[0]!.n > 0
|
||||
}
|
||||
|
||||
describe('sandbox cleanup RPCs (pg)', () => {
|
||||
it('deletes a sandbox user whose books contain posted vouchers and a booked salary run', async () => {
|
||||
const { userId, entryId } = await seedSandboxUser()
|
||||
|
||||
await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [userId])
|
||||
|
||||
expect(await authUserExists(userId)).toBe(false)
|
||||
const { rows: entries } = await getPool().query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM public.journal_entries WHERE id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
expect(entries[0]!.n).toBe(0)
|
||||
const { rows: lines } = await getPool().query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM public.journal_entry_lines WHERE journal_entry_id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
expect(lines[0]!.n).toBe(0)
|
||||
})
|
||||
|
||||
it('refuses a user whose company is not a sandbox', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, is_sandbox)
|
||||
VALUES ($1, $2, false)`,
|
||||
[userId, companyId],
|
||||
)
|
||||
|
||||
await expect(
|
||||
getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [userId]),
|
||||
).rejects.toThrow(/is not a sandbox user/i)
|
||||
expect(await authUserExists(userId)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not loosen posted-entry immutability outside the RPC', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
||||
const entryId = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId })
|
||||
|
||||
const client = await getClient()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await expect(
|
||||
client.query(`DELETE FROM public.journal_entry_lines WHERE journal_entry_id = $1`, [
|
||||
entryId,
|
||||
]),
|
||||
).rejects.toThrow(/posted journal entry/i)
|
||||
} finally {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
|
||||
it('the bypass flags are cleared before cleanup_sandbox_user returns', async () => {
|
||||
const { userId } = await seedSandboxUser()
|
||||
const client = await getClient()
|
||||
try {
|
||||
// Explicit transaction: a bare statement would end its own implicit
|
||||
// transaction and discard transaction-local GUCs regardless, which is
|
||||
// exactly the blind spot the old version of this test had.
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT public.cleanup_sandbox_user($1)`, [userId])
|
||||
const { rows } = await client.query<{ del: string | null; sc: string | null }>(
|
||||
`SELECT current_setting('gnubok.allow_delete', true) AS del,
|
||||
current_setting('gnubok.sandbox_cleanup', true) AS sc`,
|
||||
)
|
||||
expect(rows[0]!.del ?? '').not.toBe('true')
|
||||
expect(rows[0]!.sc ?? '').not.toBe('true')
|
||||
await client.query('COMMIT')
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a user who has both a sandbox and a non-sandbox company', async () => {
|
||||
const sandbox = await seedSandboxUser()
|
||||
const otherCompanyId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.companies (id, name, entity_type, created_by)
|
||||
VALUES ($1, 'Second Real Company', 'enskild_firma', $2)`,
|
||||
[otherCompanyId, sandbox.userId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, is_sandbox)
|
||||
VALUES ($1, $2, false)`,
|
||||
[sandbox.userId, otherCompanyId],
|
||||
)
|
||||
|
||||
await expect(
|
||||
getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [sandbox.userId]),
|
||||
).rejects.toThrow(/is not a sandbox user/i)
|
||||
expect(await authUserExists(sandbox.userId)).toBe(true)
|
||||
|
||||
// Clean up: replace the non-sandbox settings row with a sandbox one
|
||||
// (a direct DB session may insert is_sandbox = true), then the
|
||||
// sanctioned teardown removes everything.
|
||||
await getPool().query(
|
||||
`DELETE FROM public.company_settings WHERE company_id = $1`,
|
||||
[otherCompanyId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, is_sandbox)
|
||||
VALUES ($1, $2, true)`,
|
||||
[sandbox.userId, otherCompanyId],
|
||||
)
|
||||
await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [sandbox.userId])
|
||||
expect(await authUserExists(sandbox.userId)).toBe(false)
|
||||
})
|
||||
|
||||
it('sweeps expired sandbox users and orphaned anonymous users, keeps fresh ones, reports counts', async () => {
|
||||
const anonSupported = await hasIsAnonymousColumn()
|
||||
|
||||
// Ancient timestamps put our rows first in the ORDER BY created_at loops,
|
||||
// so a bounded p_limit still covers them even on a shared database that
|
||||
// has its own stale sandbox rows.
|
||||
const expired = await seedSandboxUser('2000-01-02T00:00:00Z')
|
||||
const fresh = await seedSandboxUser()
|
||||
const expiredOrphan = anonSupported
|
||||
? await insertAnonymousAuthUser('2000-01-01T00:00:00Z')
|
||||
: null
|
||||
const freshOrphan = anonSupported
|
||||
? await insertAnonymousAuthUser(new Date().toISOString())
|
||||
: null
|
||||
|
||||
const { rows } = await getPool().query<{
|
||||
summary: { cleaned: number; failed: number; orphans_removed: number }
|
||||
}>(`SELECT public.cleanup_expired_sandbox_users(24, 25) AS summary`)
|
||||
const summary = rows[0]!.summary
|
||||
|
||||
expect(await authUserExists(expired.userId)).toBe(false)
|
||||
expect(await authUserExists(fresh.userId)).toBe(true)
|
||||
expect(summary.cleaned).toBeGreaterThanOrEqual(1)
|
||||
expect(summary.failed).toBe(0)
|
||||
if (anonSupported && expiredOrphan && freshOrphan) {
|
||||
expect(await authUserExists(expiredOrphan)).toBe(false)
|
||||
expect(await authUserExists(freshOrphan)).toBe(true)
|
||||
expect(summary.orphans_removed).toBeGreaterThanOrEqual(1)
|
||||
} else {
|
||||
expect(summary.orphans_removed).toBe(0)
|
||||
}
|
||||
|
||||
// Leave nothing behind on a shared database.
|
||||
await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [fresh.userId])
|
||||
if (freshOrphan) {
|
||||
await getPool().query(`DELETE FROM auth.users WHERE id = $1`, [freshOrphan])
|
||||
}
|
||||
})
|
||||
|
||||
it('company_settings.is_sandbox is write-once in both directions', async () => {
|
||||
const real = await seedCompany()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, is_sandbox)
|
||||
VALUES ($1, $2, false)`,
|
||||
[real.userId, real.companyId],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.company_settings SET is_sandbox = true WHERE company_id = $1`,
|
||||
[real.companyId],
|
||||
),
|
||||
).rejects.toThrow(/write-once/i)
|
||||
|
||||
const sandbox = await seedSandboxUser()
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.company_settings SET is_sandbox = false WHERE company_id = $1`,
|
||||
[sandbox.companyId],
|
||||
),
|
||||
).rejects.toThrow(/write-once/i)
|
||||
// Other columns stay updatable.
|
||||
await getPool().query(
|
||||
`UPDATE public.company_settings SET is_sandbox = is_sandbox, company_name = 'Still Updatable'
|
||||
WHERE company_id = $1`,
|
||||
[real.companyId],
|
||||
)
|
||||
await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [sandbox.userId])
|
||||
})
|
||||
|
||||
it('the orphan sweep never reaches an anonymous user who has a company but no settings row', async () => {
|
||||
if (!(await hasIsAnonymousColumn())) return
|
||||
|
||||
const userId = await insertAnonymousAuthUser('2000-01-03T00:00:00Z')
|
||||
const companyId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.companies (id, name, entity_type, created_by)
|
||||
VALUES ($1, 'Orphan With Books', 'enskild_firma', $2)`,
|
||||
[companyId, userId],
|
||||
)
|
||||
const { rows: fpRows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.fiscal_periods (user_id, company_id, name, period_start, period_end)
|
||||
VALUES ($1, $2, 'Orphan 2026', '2026-01-01', '2026-12-31') RETURNING id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
await insertPostedJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: fpRows[0]!.id,
|
||||
})
|
||||
|
||||
await getPool().query(`SELECT public.cleanup_expired_sandbox_users(24, 25)`)
|
||||
|
||||
// Excluded from the sweep by the explicit companies/company_members
|
||||
// guards, not by an incidental downstream trigger failure.
|
||||
expect(await authUserExists(userId)).toBe(true)
|
||||
|
||||
// Clean up via the sanctioned teardown: give the company a sandbox
|
||||
// settings row (a direct DB session may insert is_sandbox = true; only
|
||||
// flips and PostgREST-authenticated inserts are blocked).
|
||||
await getPool().query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, is_sandbox)
|
||||
VALUES ($1, $2, true)`,
|
||||
[userId, companyId],
|
||||
)
|
||||
await getPool().query(`SELECT public.cleanup_sandbox_user($1)`, [userId])
|
||||
expect(await authUserExists(userId)).toBe(false)
|
||||
})
|
||||
|
||||
it('is_sandbox = true cannot be inserted by a regular authenticated user, but can by an anonymous one', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
|
||||
const client = await getClient()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
|
||||
JSON.stringify({ sub: userId, role: 'authenticated' }),
|
||||
])
|
||||
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
|
||||
await client.query(`SELECT set_config('request.jwt.claim.role', 'authenticated', true)`)
|
||||
await client.query(`SET LOCAL ROLE authenticated`)
|
||||
await expect(
|
||||
client.query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, is_sandbox)
|
||||
VALUES ($1, $2, true)`,
|
||||
[userId, companyId],
|
||||
),
|
||||
).rejects.toThrow(/anonymous sandbox users/i)
|
||||
} finally {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
client.release()
|
||||
}
|
||||
|
||||
const anonClient = await getClient()
|
||||
try {
|
||||
await anonClient.query('BEGIN')
|
||||
await anonClient.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
|
||||
JSON.stringify({ sub: userId, role: 'authenticated', is_anonymous: true }),
|
||||
])
|
||||
await anonClient.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
|
||||
await anonClient.query(
|
||||
`SELECT set_config('request.jwt.claim.role', 'authenticated', true)`,
|
||||
)
|
||||
await anonClient.query(`SET LOCAL ROLE authenticated`)
|
||||
await anonClient.query(
|
||||
`INSERT INTO public.company_settings (user_id, company_id, is_sandbox)
|
||||
VALUES ($1, $2, true)`,
|
||||
[userId, companyId],
|
||||
)
|
||||
// Rolled back below: this test only proves the guard's allow path.
|
||||
} finally {
|
||||
await anonClient.query('ROLLBACK').catch(() => {})
|
||||
anonClient.release()
|
||||
}
|
||||
})
|
||||
|
||||
it('is executable by service_role only', async () => {
|
||||
const { rows } = await getPool().query<{
|
||||
svc_user: boolean
|
||||
svc_expired: boolean
|
||||
anon_user: boolean
|
||||
anon_expired: boolean
|
||||
authed_expired: boolean
|
||||
}>(
|
||||
`SELECT
|
||||
has_function_privilege('service_role', 'public.cleanup_sandbox_user(uuid)', 'EXECUTE') AS svc_user,
|
||||
has_function_privilege('service_role', 'public.cleanup_expired_sandbox_users(int, int)', 'EXECUTE') AS svc_expired,
|
||||
has_function_privilege('anon', 'public.cleanup_sandbox_user(uuid)', 'EXECUTE') AS anon_user,
|
||||
has_function_privilege('anon', 'public.cleanup_expired_sandbox_users(int, int)', 'EXECUTE') AS anon_expired,
|
||||
has_function_privilege('authenticated', 'public.cleanup_expired_sandbox_users(int, int)', 'EXECUTE') AS authed_expired`,
|
||||
)
|
||||
expect(rows[0]!.svc_user).toBe(true)
|
||||
expect(rows[0]!.svc_expired).toBe(true)
|
||||
expect(rows[0]!.anon_user).toBe(false)
|
||||
expect(rows[0]!.anon_expired).toBe(false)
|
||||
expect(rows[0]!.authed_expired).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user