Fix/fdb fr usrs (#1125)
* fix(invoices): return attachment filename in delivery history summaries The 20260723003000 hardening dropped attachment_filename from list_invoice_delivery_summaries, so the delivery history UI always fell back to the generic "faktura.pdf" label. Recreate the RPC with the filename included: it is derived from company name, customer name, invoice number, and date, all already visible to every company member, so the minimization boundary is unchanged. Addresses stay masked and message content, BCC, and checksums stay server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): surface own-account transfer legs in match-to-voucher by default The second (incoming) leg of a transfer between two of the company's own bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog because the voucher counted as 'already matched' once its outgoing leg was linked, even though the incoming account's line had no settling transaction. Users read the empty default list as 'the app won't let me link this'. get_account_gl_lines_for_matching now counts links per settlement account: a transaction provably on another cash account no longer marks the voucher as matched for the requested account, so the unsettled transfer leg surfaces by default (and auto-selects on an exact match). Same-account N:1 stays behind the 'Visa aven matchade verifikationer' opt-in, and transactions without a resolvable cash account conservatively keep counting everywhere. get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile). Companion guard: mark_entry_as_opening_balance now refuses entries with linked bank transactions, since half-settled transfer vouchers became reachable in the reconciliation view's unmatched table where 'Mark som IB' renders; re-tagging one would strand its transaction against a movement- excluded entry. getReconciliationStatus counts unmatched GL lines with the account-scoped RPC so the status card agrees with the table. Fixes #1026 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of requests over 300ms. Target: p95 under 300ms. - requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of a second network getUser per request; getUser fallback keeps HS256 self-hosted and existing test mocks working; middleware still revocation-checks every /api request - resolve_active_company RPC (20260723161000): one round trip replaces 2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall back to the legacy query path - arsredovisning build-data: ~33 sequential round trips down to ~7, output byte-identical (snapshot-proven) - currency rate route: stop bypassing the exchange_rates cache (missing supabase arg caused an external Riksbanken call on every request) - document.get: parallelize row fetch, signed URL and audit event - list_company_accounts RPC (20260723170000): accounts list in one round trip instead of paging past PostgREST's 1000-row cap - vat-declaration route: drop a dead sequential company_settings query - get_kpi_report_aggregates RPC (20260723180000): KPI report's three full-period line scans collapsed into one aggregate call; dimension- filtered path unchanged - lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to warn, zero the eslint baseline ratchet All four gates green: lint 0 errors, 9163 tests, check:guards, build. Migrations applied idempotently to staging only; prod receives them via Supabase branching on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): resolve PR review findings across auth, VAT declaration, and IB retag - requireAuth getClaims fast path: pin iss (project URL) and aud ('authenticated'), log every fallback to getUser (ASVS V9.1 finding) - remove the ignored accountingMethod parameter from calculateVatDeclaration and the dead company_settings.accounting_method reads in xlsx/pdf/eskd routes; v1 API keeps accepting the query param but documents it as a no-op - close the mark_entry_as_opening_balance TOCTOU race with a transactions trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests; applied to staging and smoke-verified both directions - re-add the 42501 tenant guard to branch-local migration 20260723160000 (function body had silently reverted to the pre-20260619130100 definition) - document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt opening balance per BFNAR 2012:1 ch.29) - add KPI VAT-liability test covering reduced-rate output accounts 2621/2631 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard The re-added tenant guard carried the pre-20260703180000 raw NOT IN (SELECT user_company_ids()) pattern, which the null-safe-tenant-guards ratchet blocks. Staging re-synced. 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
43f7ccab9e
commit
288915c152
@@ -79,6 +79,10 @@ export function createMockSupabase() {
|
||||
error: null,
|
||||
}),
|
||||
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
createSignedUrl: vi.fn().mockResolvedValue({
|
||||
data: { signedUrl: 'https://example.com/signed' },
|
||||
error: null,
|
||||
}),
|
||||
getPublicUrl: vi.fn().mockReturnValue({
|
||||
data: { publicUrl: 'https://example.com/file.jpg' },
|
||||
}),
|
||||
@@ -772,6 +776,10 @@ export function createQueuedMockSupabase() {
|
||||
error: null,
|
||||
}),
|
||||
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
createSignedUrl: vi.fn().mockResolvedValue({
|
||||
data: { signedUrl: 'https://example.com/signed' },
|
||||
error: null,
|
||||
}),
|
||||
getPublicUrl: vi.fn().mockReturnValue({
|
||||
data: { publicUrl: 'https://example.com/file.jpg' },
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
/**
|
||||
* pg-real test for get_account_gl_lines_for_matching
|
||||
* (20260610120000_gl_lines_for_matching.sql).
|
||||
* (20260610120000_gl_lines_for_matching.sql, link-count semantics reworked in
|
||||
* 20260723160000_gl_lines_matching_account_scoped_count.sql).
|
||||
*
|
||||
* This RPC backs the N:1 "lägga på flera" feature: it mirrors get_unlinked_gl_lines
|
||||
* but can ALSO surface already-matched vouchers (so a second/third bank
|
||||
* transaction can be attached to one verifikat), each carrying how many
|
||||
* transactions already point at it.
|
||||
*
|
||||
* Since 20260723090000 the link count is scoped to the requested settlement
|
||||
* account: a transaction provably on ANOTHER cash account does not mark the
|
||||
* voucher as matched for p_account_number. This surfaces the unsettled second
|
||||
* leg of an own-account transfer by default (issue #1026) while transactions
|
||||
* with no resolvable cash account keep counting for every account.
|
||||
* (The companion mark_entry_as_opening_balance guard from the same migration
|
||||
* is covered in mark-entry-as-opening-balance.pg.test.ts.)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool } from './setup'
|
||||
import { insertAuthUser, insertCompany, insertFiscalPeriod, insertTransaction } from './fixtures'
|
||||
import {
|
||||
insertAuthUser,
|
||||
insertCashAccount,
|
||||
insertCompany,
|
||||
insertFiscalPeriod,
|
||||
insertTransaction,
|
||||
} from './fixtures'
|
||||
|
||||
async function insertPostedJournalEntry(params: {
|
||||
userId: string
|
||||
@@ -20,6 +35,8 @@ async function insertPostedJournalEntry(params: {
|
||||
sourceType: 'opening_balance' | 'manual' | 'bank_transaction' | 'import' | 'storno' | 'correction'
|
||||
voucherNumber: number
|
||||
amount?: number
|
||||
/** Line rows to book; defaults to the classic 1930 debit / 2091 credit pair. */
|
||||
lines?: Array<{ account: string; debit: number; credit: number }>
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const amount = params.amount ?? 1000
|
||||
@@ -39,13 +56,18 @@ async function insertPostedJournalEntry(params: {
|
||||
params.sourceType,
|
||||
],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, '1930', $2, 0),
|
||||
($1, '2091', 0, $2)`,
|
||||
[id, amount],
|
||||
)
|
||||
const lines = params.lines ?? [
|
||||
{ account: '1930', debit: amount, credit: 0 },
|
||||
{ account: '2091', debit: 0, credit: amount },
|
||||
]
|
||||
for (const line of lines) {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, line.account, line.debit, line.credit],
|
||||
)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -132,3 +154,130 @@ describe('get_account_gl_lines_for_matching RPC: N:1 candidates', () => {
|
||||
expect(rows.find((r) => r.source_type === 'correction')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('get_account_gl_lines_for_matching RPC: account-scoped link count (#1026)', () => {
|
||||
it('surfaces the unsettled leg of an own-account transfer by default', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({
|
||||
userId, companyId, periodStart: '2026-01-01', periodEnd: '2026-12-31',
|
||||
})
|
||||
await insertCashAccount({ companyId, ledgerAccount: '1930' })
|
||||
const acc1940 = await insertCashAccount({ companyId, ledgerAccount: '1940' })
|
||||
|
||||
// Own-account transfer: one voucher, debit 1930 / credit 1940. The outgoing
|
||||
// leg (a transaction on the 1940 account) is already matched to it.
|
||||
const transferEntry = await insertPostedJournalEntry({
|
||||
userId, companyId, fiscalPeriodId,
|
||||
entryDate: '2026-06-26', sourceType: 'manual', voucherNumber: 1,
|
||||
lines: [
|
||||
{ account: '1930', debit: 2344.16, credit: 0 },
|
||||
{ account: '1940', debit: 0, credit: 2344.16 },
|
||||
],
|
||||
})
|
||||
await insertTransaction({
|
||||
companyId, userId, amount: -2344.16, date: '2026-06-26',
|
||||
journalEntryId: transferEntry, cashAccountId: acc1940,
|
||||
})
|
||||
|
||||
// From 1930's perspective the voucher is unmatched: it must appear in the
|
||||
// DEFAULT list (no toggle) with a zero link count, so ranking/auto-select
|
||||
// treat it as a normal candidate.
|
||||
const { rows: on1930 } = await getPool().query(
|
||||
`SELECT journal_entry_id, linked_transaction_count
|
||||
FROM public.get_account_gl_lines_for_matching(p_company_id => $1, p_account_number => '1930')`,
|
||||
[companyId],
|
||||
)
|
||||
const row1930 = on1930.find((r) => r.journal_entry_id === transferEntry)
|
||||
expect(row1930).toBeDefined()
|
||||
expect(row1930.linked_transaction_count).toBe(0)
|
||||
|
||||
// From 1940's perspective it IS settled: hidden by default, visible with
|
||||
// the opt-in and carrying the link.
|
||||
const { rows: on1940Default } = await getPool().query(
|
||||
`SELECT journal_entry_id
|
||||
FROM public.get_account_gl_lines_for_matching(p_company_id => $1, p_account_number => '1940')`,
|
||||
[companyId],
|
||||
)
|
||||
expect(on1940Default.find((r) => r.journal_entry_id === transferEntry)).toBeUndefined()
|
||||
|
||||
const { rows: on1940Matched } = await getPool().query(
|
||||
`SELECT journal_entry_id, linked_transaction_count
|
||||
FROM public.get_account_gl_lines_for_matching(
|
||||
p_company_id => $1, p_account_number => '1940', p_include_matched => true)`,
|
||||
[companyId],
|
||||
)
|
||||
expect(on1940Matched.find((r) => r.journal_entry_id === transferEntry).linked_transaction_count).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps same-account N:1 vouchers behind the include_matched opt-in', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({
|
||||
userId, companyId, periodStart: '2026-01-01', periodEnd: '2026-12-31',
|
||||
})
|
||||
const acc1930 = await insertCashAccount({ companyId, ledgerAccount: '1930' })
|
||||
|
||||
// A salary-run shape: one voucher on 1930, partially settled by a first
|
||||
// transfer FROM THE SAME account. The second instalment must still require
|
||||
// the deliberate opt-in; account scoping must not open the N:1 floodgate.
|
||||
const salaryEntry = await insertPostedJournalEntry({
|
||||
userId, companyId, fiscalPeriodId,
|
||||
entryDate: '2026-06-25', sourceType: 'manual', voucherNumber: 1, amount: 30000,
|
||||
})
|
||||
await insertTransaction({
|
||||
companyId, userId, amount: -10000, date: '2026-06-25',
|
||||
journalEntryId: salaryEntry, cashAccountId: acc1930,
|
||||
})
|
||||
|
||||
const { rows: byDefault } = await getPool().query(
|
||||
`SELECT journal_entry_id
|
||||
FROM public.get_account_gl_lines_for_matching(p_company_id => $1, p_account_number => '1930')`,
|
||||
[companyId],
|
||||
)
|
||||
expect(byDefault.find((r) => r.journal_entry_id === salaryEntry)).toBeUndefined()
|
||||
|
||||
const { rows: withMatched } = await getPool().query(
|
||||
`SELECT journal_entry_id, linked_transaction_count
|
||||
FROM public.get_account_gl_lines_for_matching(
|
||||
p_company_id => $1, p_account_number => '1930', p_include_matched => true)`,
|
||||
[companyId],
|
||||
)
|
||||
expect(withMatched.find((r) => r.journal_entry_id === salaryEntry).linked_transaction_count).toBe(1)
|
||||
})
|
||||
|
||||
it('treats transactions without a resolvable cash account as settling every account', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({
|
||||
userId, companyId, periodStart: '2026-01-01', periodEnd: '2026-12-31',
|
||||
})
|
||||
|
||||
// Legacy shape: the linked transaction carries no cash_account_id, so it
|
||||
// could belong to any account. The voucher must stay hidden by default
|
||||
// (conservative: pre-account-scoping behavior).
|
||||
const legacyEntry = await insertPostedJournalEntry({
|
||||
userId, companyId, fiscalPeriodId,
|
||||
entryDate: '2026-06-20', sourceType: 'bank_transaction', voucherNumber: 1, amount: 500,
|
||||
})
|
||||
await insertTransaction({
|
||||
companyId, userId, amount: 500, date: '2026-06-20',
|
||||
journalEntryId: legacyEntry, cashAccountId: null,
|
||||
})
|
||||
|
||||
const { rows: byDefault } = await getPool().query(
|
||||
`SELECT journal_entry_id
|
||||
FROM public.get_account_gl_lines_for_matching(p_company_id => $1, p_account_number => '1930')`,
|
||||
[companyId],
|
||||
)
|
||||
expect(byDefault.find((r) => r.journal_entry_id === legacyEntry)).toBeUndefined()
|
||||
|
||||
const { rows: withMatched } = await getPool().query(
|
||||
`SELECT journal_entry_id, linked_transaction_count
|
||||
FROM public.get_account_gl_lines_for_matching(
|
||||
p_company_id => $1, p_account_number => '1930', p_include_matched => true)`,
|
||||
[companyId],
|
||||
)
|
||||
expect(withMatched.find((r) => r.journal_entry_id === legacyEntry).linked_transaction_count).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* pg-real test for get_kpi_report_aggregates.
|
||||
*
|
||||
* The RPC backs the KPI report's no-dimension hot path: one SQL pass
|
||||
* returns the per-account trial-balance sums (with and without the
|
||||
* year-end chain), the opening-balance entry's sums, and per-month
|
||||
* income/expenses. The exclusion semantics used to live in JS
|
||||
* (lib/reports/trial-balance.ts + monthly-breakdown.ts) behind PostgREST
|
||||
* filters; this suite pins them against real Postgres:
|
||||
*
|
||||
* - tb covers posted AND reversed entries, excluding ONLY the
|
||||
* opening-balance entry (p_ob_entry_id);
|
||||
* - tb_ex_year_end additionally drops source_type year_end entries and
|
||||
* the stornos/corrections of REVERSED year-end entries (the undone
|
||||
* year-end chain), mirroring excludeYearEndClosing;
|
||||
* - ob sums the OB entry's lines with NO status filter (mirrors
|
||||
* getOpeningBalances) but is company-guarded;
|
||||
* - monthly is posted-only, classes 3-8, 8999 excluded, class 8 split
|
||||
* per line by the sign of credit - debit;
|
||||
* - SECURITY INVOKER: a non-member gets empty sections under RLS.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import {
|
||||
insertAuthUser,
|
||||
insertCompany,
|
||||
insertCompanyMember,
|
||||
insertFiscalPeriod,
|
||||
} from './fixtures'
|
||||
|
||||
interface AccountSums {
|
||||
account_number: string
|
||||
debit: number
|
||||
credit: number
|
||||
}
|
||||
|
||||
interface RpcPayload {
|
||||
tb: AccountSums[]
|
||||
tb_ex_year_end: AccountSums[]
|
||||
ob: AccountSums[]
|
||||
monthly: Array<{ year: number; month: number; income: number; expenses: number }>
|
||||
}
|
||||
|
||||
async function callRpc(
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
obEntryId: string | null = null,
|
||||
): Promise<RpcPayload> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT public.get_kpi_report_aggregates($1, $2, $3) AS payload`,
|
||||
[companyId, fiscalPeriodId, obEntryId],
|
||||
)
|
||||
return rows[0].payload as RpcPayload
|
||||
}
|
||||
|
||||
function byAccount(section: AccountSums[]) {
|
||||
return new Map(section.map((t) => [t.account_number, t]))
|
||||
}
|
||||
|
||||
function monthOf(payload: RpcPayload, year: number, month: number) {
|
||||
return payload.monthly.find((m) => m.year === year && m.month === month)
|
||||
}
|
||||
|
||||
async function insertJournalEntry(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
voucherNumber: number
|
||||
status?: 'draft' | 'posted' | 'reversed'
|
||||
sourceType?: string
|
||||
entryDate?: string
|
||||
reversesId?: string | null
|
||||
correctionOfId?: string | null
|
||||
lines: Array<{ account: string; debit: number; credit: number }>
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
// Insert directly, bypassing commit_journal_entry's voucher sequencing:
|
||||
// fine for a read-side RPC that only aggregates line/account references.
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
|
||||
entry_date, description, source_type, status, reverses_id, correction_of_id)
|
||||
VALUES ($1, $2, $3, $4, $5, 'A', $6, 'KPI RPC test', $7, $8, $9, $10)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.fiscalPeriodId,
|
||||
params.voucherNumber,
|
||||
params.entryDate ?? '2026-03-15',
|
||||
params.sourceType ?? 'manual',
|
||||
params.status ?? 'posted',
|
||||
params.reversesId ?? null,
|
||||
params.correctionOfId ?? null,
|
||||
],
|
||||
)
|
||||
for (const line of params.lines) {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, line.account, line.debit, line.credit],
|
||||
)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function seedCompany() {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
|
||||
return { userId, companyId, fiscalPeriodId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Full scenario: posted entries across three months, a reversed manual
|
||||
* entry, an undone year-end chain (reversed year_end + storno +
|
||||
* correction), a still-posted year_end entry with 8999 and 8910, and a
|
||||
* linked OB entry.
|
||||
*/
|
||||
async function seedFullScenario() {
|
||||
const ctx = await seedCompany()
|
||||
|
||||
const obEntryId = await insertJournalEntry({
|
||||
...ctx, voucherNumber: 1, sourceType: 'opening_balance', entryDate: '2026-01-01',
|
||||
lines: [
|
||||
{ account: '1930', debit: 5000, credit: 0 },
|
||||
{ account: '2010', debit: 0, credit: 5000 },
|
||||
],
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET opening_balance_entry_id = $1 WHERE id = $2`,
|
||||
[obEntryId, ctx.fiscalPeriodId],
|
||||
)
|
||||
|
||||
// January: revenue
|
||||
await insertJournalEntry({
|
||||
...ctx, voucherNumber: 2, entryDate: '2026-01-15',
|
||||
lines: [
|
||||
{ account: '3001', debit: 0, credit: 10000 },
|
||||
{ account: '2611', debit: 0, credit: 2500 },
|
||||
{ account: '1930', debit: 12500, credit: 0 },
|
||||
],
|
||||
})
|
||||
// February: expense, plus a REVERSED entry (in tb, not in monthly)
|
||||
await insertJournalEntry({
|
||||
...ctx, voucherNumber: 3, entryDate: '2026-02-10',
|
||||
lines: [
|
||||
{ account: '5010', debit: 3000, credit: 0 },
|
||||
{ account: '1930', debit: 0, credit: 3000 },
|
||||
],
|
||||
})
|
||||
await insertJournalEntry({
|
||||
...ctx, voucherNumber: 4, status: 'reversed', entryDate: '2026-02-20',
|
||||
lines: [{ account: '3001', debit: 0, credit: 700 }],
|
||||
})
|
||||
// March: mixed-sign class 8 lines in the same entry
|
||||
await insertJournalEntry({
|
||||
...ctx, voucherNumber: 5, entryDate: '2026-03-05',
|
||||
lines: [
|
||||
{ account: '8310', debit: 0, credit: 200 },
|
||||
{ account: '8410', debit: 500, credit: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
// December: posted year_end entry (8999 + 8910)
|
||||
await insertJournalEntry({
|
||||
...ctx, voucherNumber: 6, sourceType: 'year_end', entryDate: '2026-12-31',
|
||||
lines: [
|
||||
{ account: '8910', debit: 1000, credit: 0 },
|
||||
{ account: '2512', debit: 0, credit: 1000 },
|
||||
{ account: '8999', debit: 5000, credit: 0 },
|
||||
{ account: '2099', debit: 0, credit: 5000 },
|
||||
],
|
||||
})
|
||||
// Undone year-end chain: reversed year_end + its storno + a correction
|
||||
const reversedYearEndId = await insertJournalEntry({
|
||||
...ctx, voucherNumber: 7, sourceType: 'year_end', status: 'reversed', entryDate: '2026-12-31',
|
||||
lines: [
|
||||
{ account: '8999', debit: 400, credit: 0 },
|
||||
{ account: '2099', debit: 0, credit: 400 },
|
||||
],
|
||||
})
|
||||
await insertJournalEntry({
|
||||
...ctx, voucherNumber: 8, sourceType: 'storno', entryDate: '2026-12-31',
|
||||
reversesId: reversedYearEndId,
|
||||
lines: [
|
||||
{ account: '8999', debit: 0, credit: 400 },
|
||||
{ account: '2099', debit: 400, credit: 0 },
|
||||
],
|
||||
})
|
||||
await insertJournalEntry({
|
||||
...ctx, voucherNumber: 9, sourceType: 'correction', entryDate: '2026-12-31',
|
||||
correctionOfId: reversedYearEndId,
|
||||
lines: [
|
||||
{ account: '6200', debit: 250, credit: 0 },
|
||||
{ account: '1930', debit: 0, credit: 250 },
|
||||
],
|
||||
})
|
||||
|
||||
return { ...ctx, obEntryId }
|
||||
}
|
||||
|
||||
describe('get_kpi_report_aggregates RPC', () => {
|
||||
it('tb covers posted and reversed period entries, excluding only the OB entry', async () => {
|
||||
const ctx = await seedFullScenario()
|
||||
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId)
|
||||
const tb = byAccount(payload.tb)
|
||||
|
||||
// OB entry excluded: 1930 period debit is 12500, not 17500; 2010 absent.
|
||||
expect(tb.get('1930')).toMatchObject({ debit: 12500, credit: 3250 })
|
||||
expect(tb.has('2010')).toBe(false)
|
||||
// Reversed manual entry included (posted + reversed base filter).
|
||||
expect(tb.get('3001')).toMatchObject({ debit: 0, credit: 10700 })
|
||||
expect(tb.get('2611')).toMatchObject({ debit: 0, credit: 2500 })
|
||||
expect(tb.get('5010')).toMatchObject({ debit: 3000, credit: 0 })
|
||||
// Year-end chain present in the plain tb.
|
||||
expect(tb.get('8999')).toMatchObject({ debit: 5400, credit: 400 })
|
||||
expect(tb.get('2099')).toMatchObject({ debit: 400, credit: 5400 })
|
||||
expect(tb.get('8910')).toMatchObject({ debit: 1000, credit: 0 })
|
||||
expect(tb.get('6200')).toMatchObject({ debit: 250, credit: 0 })
|
||||
})
|
||||
|
||||
it('tb includes the OB entry when p_ob_entry_id is NULL, and ob is empty', async () => {
|
||||
const ctx = await seedFullScenario()
|
||||
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, null)
|
||||
|
||||
expect(byAccount(payload.tb).get('1930')).toMatchObject({ debit: 17500, credit: 3250 })
|
||||
expect(byAccount(payload.tb).get('2010')).toMatchObject({ debit: 0, credit: 5000 })
|
||||
expect(payload.ob).toEqual([])
|
||||
})
|
||||
|
||||
it('tb_ex_year_end drops year_end entries plus stornos/corrections of reversed year-ends', async () => {
|
||||
const ctx = await seedFullScenario()
|
||||
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId)
|
||||
const tb = byAccount(payload.tb_ex_year_end)
|
||||
|
||||
// Ordinary activity retained...
|
||||
expect(tb.get('3001')).toMatchObject({ debit: 0, credit: 10700 })
|
||||
expect(tb.get('5010')).toMatchObject({ debit: 3000, credit: 0 })
|
||||
expect(tb.get('8310')).toMatchObject({ debit: 0, credit: 200 })
|
||||
expect(tb.get('8410')).toMatchObject({ debit: 500, credit: 0 })
|
||||
// ...the whole year-end chain gone: year_end entries, the storno that
|
||||
// reverses one, and the correction that corrects one.
|
||||
expect(tb.has('8999')).toBe(false)
|
||||
expect(tb.has('2099')).toBe(false)
|
||||
expect(tb.has('8910')).toBe(false)
|
||||
expect(tb.has('2512')).toBe(false)
|
||||
expect(tb.has('6200')).toBe(false)
|
||||
// The correction's 1930 credit (250) disappears with it.
|
||||
expect(tb.get('1930')).toMatchObject({ debit: 12500, credit: 3000 })
|
||||
})
|
||||
|
||||
it('keeps stornos/corrections that do not point at a reversed year-end', async () => {
|
||||
const ctx = await seedCompany()
|
||||
const plainReversed = await insertJournalEntry({
|
||||
...ctx, voucherNumber: 1, status: 'reversed', entryDate: '2026-04-01',
|
||||
lines: [{ account: '5010', debit: 100, credit: 0 }],
|
||||
})
|
||||
await insertJournalEntry({
|
||||
...ctx, voucherNumber: 2, sourceType: 'storno', entryDate: '2026-04-02',
|
||||
reversesId: plainReversed,
|
||||
lines: [{ account: '5010', debit: 0, credit: 100 }],
|
||||
})
|
||||
|
||||
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId)
|
||||
// Only reversals of REVERSED year_end entries are chained out.
|
||||
expect(byAccount(payload.tb_ex_year_end).get('5010')).toMatchObject({
|
||||
debit: 100,
|
||||
credit: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('ob sums the OB entry regardless of status, guarded by company', async () => {
|
||||
const ctx = await seedCompany()
|
||||
// Draft OB entry: getOpeningBalances has no status filter, neither may we.
|
||||
const draftOb = await insertJournalEntry({
|
||||
...ctx, voucherNumber: 1, status: 'draft', sourceType: 'opening_balance',
|
||||
entryDate: '2026-01-01',
|
||||
lines: [
|
||||
{ account: '1930', debit: 800, credit: 0 },
|
||||
{ account: '2081', debit: 0, credit: 800 },
|
||||
],
|
||||
})
|
||||
|
||||
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, draftOb)
|
||||
expect(byAccount(payload.ob).get('1930')).toMatchObject({ debit: 800, credit: 0 })
|
||||
expect(byAccount(payload.ob).get('2081')).toMatchObject({ debit: 0, credit: 800 })
|
||||
// Draft entries never enter tb.
|
||||
expect(payload.tb).toEqual([])
|
||||
|
||||
// Another company's entry id yields nothing (defense in depth for
|
||||
// service-role callers that bypass RLS).
|
||||
const other = await seedCompany()
|
||||
const foreign = await callRpc(other.companyId, other.fiscalPeriodId, draftOb)
|
||||
expect(foreign.ob).toEqual([])
|
||||
})
|
||||
|
||||
it('monthly is posted-only, 8999 excluded, class 8 sign-split per line', async () => {
|
||||
const ctx = await seedFullScenario()
|
||||
const payload = await callRpc(ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId)
|
||||
|
||||
// January: revenue only (class 2 VAT line ignored).
|
||||
expect(monthOf(payload, 2026, 1)).toMatchObject({ income: 10000, expenses: 0 })
|
||||
// February: the reversed 3001 entry (700) must NOT appear.
|
||||
expect(monthOf(payload, 2026, 2)).toMatchObject({ income: 0, expenses: 3000 })
|
||||
// March: 8310 credit 200 -> income; 8410 debit 500 -> expenses.
|
||||
expect(monthOf(payload, 2026, 3)).toMatchObject({ income: 200, expenses: 500 })
|
||||
// December: year_end entries are NOT excluded from monthly. 8910 debit
|
||||
// 1000 -> expenses; posted storno's 8999 credit excluded by account; the
|
||||
// correction's 6200 debit 250 -> expenses. Total 1250.
|
||||
expect(monthOf(payload, 2026, 12)).toMatchObject({ income: 0, expenses: 1250 })
|
||||
// No phantom months.
|
||||
expect(payload.monthly.map((m) => m.month).sort((a, b) => a - b)).toEqual([1, 2, 3, 12])
|
||||
})
|
||||
|
||||
it('scopes to the requested company and returns empty sections for an empty one', async () => {
|
||||
const a = await seedFullScenario()
|
||||
const b = await seedCompany()
|
||||
|
||||
const payload = await callRpc(b.companyId, b.fiscalPeriodId)
|
||||
expect(payload.tb).toEqual([])
|
||||
expect(payload.tb_ex_year_end).toEqual([])
|
||||
expect(payload.ob).toEqual([])
|
||||
expect(payload.monthly).toEqual([])
|
||||
|
||||
// And company A's data is intact when asked for correctly.
|
||||
const payloadA = await callRpc(a.companyId, a.fiscalPeriodId, a.obEntryId)
|
||||
expect(payloadA.tb.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('SECURITY INVOKER: a member reads sums, a non-member gets empty sections under RLS', async () => {
|
||||
const ctx = await seedFullScenario()
|
||||
const outsider = await seedCompany() // member of their own company only
|
||||
|
||||
const asMember = await withUserContext(ctx.userId, async (client) => {
|
||||
const { rows } = await client.query(
|
||||
`SELECT public.get_kpi_report_aggregates($1, $2, $3) AS payload`,
|
||||
[ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId],
|
||||
)
|
||||
return rows[0].payload as RpcPayload
|
||||
})
|
||||
expect(byAccount(asMember.tb).get('3001')).toMatchObject({ debit: 0, credit: 10700 })
|
||||
expect(byAccount(asMember.ob).get('1930')).toMatchObject({ debit: 5000, credit: 0 })
|
||||
|
||||
const asOutsider = await withUserContext(outsider.userId, async (client) => {
|
||||
const { rows } = await client.query(
|
||||
`SELECT public.get_kpi_report_aggregates($1, $2, $3) AS payload`,
|
||||
[ctx.companyId, ctx.fiscalPeriodId, ctx.obEntryId],
|
||||
)
|
||||
return rows[0].payload as RpcPayload
|
||||
})
|
||||
expect(asOutsider.tb).toEqual([])
|
||||
expect(asOutsider.tb_ex_year_end).toEqual([])
|
||||
expect(asOutsider.ob).toEqual([])
|
||||
expect(asOutsider.monthly).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
insertCompany,
|
||||
insertCompanyMember,
|
||||
insertFiscalPeriod,
|
||||
insertTransaction,
|
||||
} from '@/tests/pg/fixtures'
|
||||
import { getClient, getPool, withUserContext } from '@/tests/pg/setup'
|
||||
|
||||
@@ -160,6 +161,34 @@ describe('mark_entry_as_opening_balance RPC', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses an entry with a linked bank transaction (20260723160000 guard)', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
|
||||
// A half-settled own-account transfer: since the account-scoped matching
|
||||
// semantics (same migration), this voucher surfaces in the reconciliation
|
||||
// view's unmatched table on the OTHER account, making "Märk som IB"
|
||||
// reachable. Re-tagging it would strand the linked transaction against an
|
||||
// excluded entry, so the RPC must refuse.
|
||||
const entryId = await insertPostedEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 1,
|
||||
lines: [
|
||||
{ account: '1930', debit: 2500, credit: 0 },
|
||||
{ account: '1940', debit: 0, credit: 2500 },
|
||||
],
|
||||
})
|
||||
await insertTransaction({
|
||||
companyId, userId, amount: -2500, journalEntryId: entryId,
|
||||
})
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await expect(
|
||||
client.query(`SELECT mark_entry_as_opening_balance($1, $2)`, [companyId, entryId]),
|
||||
).rejects.toThrow(/linked bank transactions/i)
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses re-tagging in a locked fiscal period', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
@@ -255,6 +284,78 @@ describe('enforce_journal_entry_immutability: source_type retag carve-out', () =
|
||||
})
|
||||
})
|
||||
|
||||
describe('check_transaction_link_not_opening_balance trigger (20260723190000)', () => {
|
||||
it('refuses INSERTing a transaction linked to an opening_balance entry', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedOwner()
|
||||
const entryId = await insertPostedEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 1, sourceType: 'opening_balance',
|
||||
})
|
||||
|
||||
await expect(
|
||||
insertTransaction({ companyId, userId, amount: -2500, journalEntryId: entryId }),
|
||||
).rejects.toThrow(/opening balance entry/i)
|
||||
})
|
||||
|
||||
it('refuses UPDATEing journal_entry_id to point at an opening_balance entry', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedOwner()
|
||||
const obEntryId = await insertPostedEntry({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: 1, sourceType: 'opening_balance',
|
||||
})
|
||||
const txId = await insertTransaction({ companyId, userId, amount: -2500 })
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`,
|
||||
[obEntryId, txId],
|
||||
),
|
||||
).rejects.toThrow(/opening balance entry/i)
|
||||
})
|
||||
|
||||
it('allows linking to an ordinary entry and unlinking back to NULL', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedOwner()
|
||||
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
|
||||
const txId = await insertTransaction({ companyId, userId, amount: -2500 })
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions SET journal_entry_id = $1 WHERE id = $2`,
|
||||
[entryId, txId],
|
||||
)
|
||||
const linked = await getPool().query<{ journal_entry_id: string | null }>(
|
||||
`SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(linked.rows[0]!.journal_entry_id).toBe(entryId)
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions SET journal_entry_id = NULL WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
const unlinked = await getPool().query<{ journal_entry_id: string | null }>(
|
||||
`SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(unlinked.rows[0]!.journal_entry_id).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves updates that do not change journal_entry_id alone', async () => {
|
||||
const { userId, companyId, fiscalPeriodId } = await seedOwner()
|
||||
const entryId = await insertPostedEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 })
|
||||
const txId = await insertTransaction({ companyId, userId, amount: -2500, journalEntryId: entryId })
|
||||
|
||||
// Same-value SET (e.g. a generic column-list UPDATE) must not raise even
|
||||
// though the trigger's UPDATE OF column list matches.
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions SET journal_entry_id = journal_entry_id, description = 'touched' WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
const after = await getPool().query<{ description: string }>(
|
||||
`SELECT description FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(after.rows[0]!.description).toBe('touched')
|
||||
})
|
||||
})
|
||||
|
||||
// Local owner seed (company + owner membership + open period).
|
||||
async function seedOwner(): Promise<{ userId: string; companyId: string; fiscalPeriodId: string }> {
|
||||
const userId = await insertAuthUser()
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getPool, getClient, withUserContext } from './setup'
|
||||
import { insertAuthUser, insertCompany, insertCompanyMember, seedCompany } from './fixtures'
|
||||
|
||||
// Validates migration 20260723161000_resolve_active_company_rpc:
|
||||
// 1. resolve_active_company() mirrors the JS resolution exactly: validated
|
||||
// preference wins, else earliest non-archived membership by
|
||||
// company_members.created_at, else NULL.
|
||||
// 2. used_fallback is true exactly when the validated preference is
|
||||
// absent (middleware's write-back condition).
|
||||
// 3. NULL auth.uid() (service-role clients) returns ZERO rows by design.
|
||||
// 4. EXECUTE is granted to authenticated only, not anon.
|
||||
// 5. Divergence guard: it never disagrees with
|
||||
// current_active_company_id(), which RLS reads.
|
||||
|
||||
const RESOLVE = `SELECT r.company_id::text AS company_id, r.locale, r.used_fallback
|
||||
FROM public.resolve_active_company() r`
|
||||
|
||||
type ResolveRow = { company_id: string | null; locale: string | null; used_fallback: boolean }
|
||||
|
||||
async function setPrefs(
|
||||
userId: string,
|
||||
activeCompanyId: string | null,
|
||||
locale?: string,
|
||||
): Promise<void> {
|
||||
if (locale === undefined) {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.user_preferences (user_id, active_company_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
|
||||
[userId, activeCompanyId],
|
||||
)
|
||||
return
|
||||
}
|
||||
await getPool().query(
|
||||
`INSERT INTO public.user_preferences (user_id, active_company_id, locale)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET active_company_id = EXCLUDED.active_company_id, locale = EXCLUDED.locale`,
|
||||
[userId, activeCompanyId, locale],
|
||||
)
|
||||
}
|
||||
|
||||
describe('resolve_active_company()', () => {
|
||||
it('resolves a valid preference with used_fallback false and the default locale', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await setPrefs(userId, companyId)
|
||||
await withUserContext(userId, async (client) => {
|
||||
const res = await client.query<ResolveRow>(RESOLVE)
|
||||
expect(res.rows).toHaveLength(1)
|
||||
expect(res.rows[0].company_id).toBe(companyId)
|
||||
expect(res.rows[0].used_fallback).toBe(false)
|
||||
// locale column is NOT NULL DEFAULT 'sv' (20260521120000): a prefs row
|
||||
// always carries a locale.
|
||||
expect(res.rows[0].locale).toBe('sv')
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the first membership with used_fallback true and null locale when there is no prefs row', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await withUserContext(userId, async (client) => {
|
||||
const res = await client.query<ResolveRow>(RESOLVE)
|
||||
expect(res.rows).toHaveLength(1)
|
||||
expect(res.rows[0].company_id).toBe(companyId)
|
||||
expect(res.rows[0].used_fallback).toBe(true)
|
||||
expect(res.rows[0].locale).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('orders the fallback by company_members.created_at ASC, not insertion order', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const firstInserted = await insertCompany({ createdBy: userId, name: 'First Inserted AB' })
|
||||
const backdated = await insertCompany({ createdBy: userId, name: 'Backdated AB' })
|
||||
await insertCompanyMember({ companyId: firstInserted, userId })
|
||||
await insertCompanyMember({ companyId: backdated, userId })
|
||||
// Backdate the SECOND membership: it must win despite later insertion.
|
||||
await getPool().query(
|
||||
`UPDATE public.company_members SET created_at = now() - interval '1 day'
|
||||
WHERE company_id = $1 AND user_id = $2`,
|
||||
[backdated, userId],
|
||||
)
|
||||
await withUserContext(userId, async (client) => {
|
||||
const res = await client.query<ResolveRow>(RESOLVE)
|
||||
expect(res.rows[0].company_id).toBe(backdated)
|
||||
expect(res.rows[0].used_fallback).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a preference pointing at an archived company and falls back', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const archived = await insertCompany({ createdBy: userId, name: 'Archived AB' })
|
||||
const alive = await insertCompany({ createdBy: userId, name: 'Alive AB' })
|
||||
await insertCompanyMember({ companyId: archived, userId })
|
||||
await insertCompanyMember({ companyId: alive, userId })
|
||||
await getPool().query(`UPDATE public.companies SET archived_at = now() WHERE id = $1`, [
|
||||
archived,
|
||||
])
|
||||
await setPrefs(userId, archived)
|
||||
await withUserContext(userId, async (client) => {
|
||||
const res = await client.query<ResolveRow>(RESOLVE)
|
||||
expect(res.rows[0].company_id).toBe(alive)
|
||||
expect(res.rows[0].used_fallback).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a preference pointing at a company the user is not a member of', async () => {
|
||||
const { companyId: foreignCompany } = await seedCompany() // someone else's
|
||||
const { userId, companyId: ownCompany } = await seedCompany()
|
||||
await setPrefs(userId, foreignCompany)
|
||||
await withUserContext(userId, async (client) => {
|
||||
const res = await client.query<ResolveRow>(RESOLVE)
|
||||
expect(res.rows[0].company_id).toBe(ownCompany)
|
||||
expect(res.rows[0].used_fallback).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns one row with a null company_id but the stored locale for a user with prefs and zero memberships', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
await setPrefs(userId, null, 'en')
|
||||
await withUserContext(userId, async (client) => {
|
||||
const res = await client.query<ResolveRow>(RESOLVE)
|
||||
expect(res.rows).toHaveLength(1)
|
||||
expect(res.rows[0].company_id).toBeNull()
|
||||
expect(res.rows[0].locale).toBe('en')
|
||||
expect(res.rows[0].used_fallback).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns ZERO rows when auth.uid() is NULL (service-role clients)', async () => {
|
||||
const client = await getClient()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
// A claims object with no `sub`: auth.uid() resolves to NULL, the
|
||||
// shape a service-role/backend connection presents.
|
||||
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
|
||||
JSON.stringify({ role: 'authenticated' }),
|
||||
])
|
||||
await client.query(`SET LOCAL ROLE authenticated`)
|
||||
const res = await client.query<ResolveRow>(RESOLVE)
|
||||
expect(res.rows).toHaveLength(0)
|
||||
await client.query('ROLLBACK')
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
|
||||
it('grants EXECUTE to authenticated but not anon', async () => {
|
||||
const res = await getPool().query<{ anon_can: boolean; authenticated_can: boolean }>(
|
||||
`SELECT
|
||||
has_function_privilege('anon', 'public.resolve_active_company()', 'EXECUTE') AS anon_can,
|
||||
has_function_privilege('authenticated', 'public.resolve_active_company()', 'EXECUTE') AS authenticated_can`,
|
||||
)
|
||||
expect(res.rows[0].anon_can).toBe(false)
|
||||
expect(res.rows[0].authenticated_can).toBe(true)
|
||||
})
|
||||
|
||||
it('never diverges from current_active_company_id(), which RLS reads', async () => {
|
||||
// Stale-pref scenario: the most divergence-prone shape (pref set, but
|
||||
// membership on the preferred company is gone).
|
||||
const { companyId: foreignCompany } = await seedCompany()
|
||||
const { userId } = await seedCompany()
|
||||
await setPrefs(userId, foreignCompany)
|
||||
await withUserContext(userId, async (client) => {
|
||||
const res = await client.query<{ agrees: boolean }>(
|
||||
`SELECT (SELECT r.company_id FROM public.resolve_active_company() r)
|
||||
IS NOT DISTINCT FROM public.current_active_company_id() AS agrees`,
|
||||
)
|
||||
expect(res.rows[0].agrees).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user