Files
accounted/tests/pg/woocommerce-connections.pg.test.ts
T
MattssonandClaude Fable 5.1 e0244b95a7 fix(woocommerce): require both verified keys and browser confirmation to activate a connection (#2375)
* fix(woocommerce): require both verified keys and browser confirmation to activate a connection

The wc-auth callback alone used to flip a connection to active. Until the
initiator's browser reached the return route the row was syncable, so an
approver who never came back (closed tab, skipped sign-in, or lured into
approving a connect someone else started) left their store's keys active
inside another company's books, reachable by manual sync within seconds.

Now the callback only stages the verified keys on the pending row, the
return leg records browser_confirmed_at after the initiator check, and one
conditional update flips the row to active exactly once when both signals
are present, in either arrival order. A DB CHECK (20260907100000) makes an
active row without both signals impossible; every consumer selects
status = 'active', so staged keys can never sync.

Also: 15-minute handshake TTL on both legs, duplicate callback refused,
stale pending rows swept (keys wiped) at the start of the nightly orders
cron, manual key entry records the confirmation itself, every path that
closes a pending row wipes staged keys, expired/conflict toasts in sv + en.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

* fix(woocommerce): drop the callback-leg TTL and state the gate's real scope

Skeptic pass on the activation gate:

- WooCommerce answers any non-200 callback response by deleting the key it
  just minted and showing a store-side error page, never redirecting back.
  A 410 for a slow approval therefore stranded the merchant. The callback
  now stages regardless of age; the session-bound return leg and the nightly
  sweep enforce expiry, and a stale pending row cannot sync either way.
- The second activation signal comes from the initiating user, so the gate
  does not stop a store admin from approving a link someone else generated
  (wc-auth delivers keys server-to-server and identifies no approver). The
  migration header, route comments, decision log and PR body now say so
  instead of claiming otherwise. Proof of store control is a follow-up.
- Every path that parks a pending row also wipes the store metadata the
  probe staged, so a refused handshake leaves nothing of the store behind.
- A duplicate callback POST is a 200 no-op instead of a 409, so the status
  code no longer tells the state holder whether the merchant has approved.
- The expiry message tells the user to remove the unused key in the store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

* fix(woocommerce): carry the handshake TTL in the activation flip, wipe metadata on denial

Re-verification of the skeptic fixes found two gaps:

- The initiator can open the return URL early, so a row confirmed at
  minute one still flipped when the keys landed hours later; the callback
  no longer refuses stale rows, so nothing bounded that. The conditional
  activation update now also requires created_at within the TTL. The
  callback still answers 200 (no store-side wp_die); the flip matches zero
  rows and the sweep parks the row. Covered by a pg-real case with both
  signals present on a 20-minute-old row.
- The store-denied path parked the row without clearing the staged store
  metadata. It now wipes the same five columns as every other parking path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

* chore(migrations): move the WooCommerce activation gate to 20260907143000 after main took 20260907100000

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

* fix(woocommerce): make browser_confirmed_at server-only, finish replayed callbacks, budget the cron sweep

Review round on PR #2375:

- Superagent P1: browser_confirmed_at was member-writable through the
  row-scoped RLS policy, so any writer of the company could supply the
  initiator's signal on a colleague's pending row. New migration
  20260907150000 adds a trigger keyed on the JWT role claim (same pattern as
  enforce_company_writer_role): end-user sessions cannot insert or update
  the column, service role and migrations pass. Manual key entry now
  inserts on the service client with company_id/user_id from the verified
  context. pg-real covers refusal on insert and update plus the server path.
- CodeRabbit: a replayed callback for an already-keyed row now runs the
  activation flip instead of returning early, so a callback cut off between
  staging and activating is completed by its retry.
- CodeRabbit: the cron deadline is fixed before the stale-handshake sweep,
  so the sweep counts against the route's maxDuration budget.
- CodeRabbit: the activation CHECK is added NOT VALID (rows were already
  conformed by the backfill) and validated in 20260907150000 under the
  weaker lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

* fix(woocommerce): make activation itself server-only, install the trigger before validating the gate

Second review round on PR #2375:

- CodeRabbit: an authenticated company member could still UPDATE ... SET
  status = 'active' on a fully staged row through PostgREST; the CHECK only
  proves both signals exist, and the 15-minute TTL lives in the server's
  conditional activation update. The server-only trigger now also refuses
  any end-user transition into 'active' (insert or update). Leaving
  'active' (disconnect, supersede, revoked-key marking) stays
  member-writable. pg-real covers an expired, fully staged row: 42501 from
  a user session, then a member disconnect after the server activates.
- Superagent P2: the VALIDATE CONSTRAINT now runs after the trigger is
  installed, so the gate is never enforced while its signal is still
  member-writable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:54:28 +02:00

373 lines
16 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { getPool, withUserContext } from './setup'
import { randomUUID } from 'crypto'
import { seedCompany } from './fixtures'
// Committed (pool) inserts persist across pg-real runs, and the store_url
// partial unique index is global: fixed URLs would collide with rows left by
// a previous run before the assertion under test is ever reached.
const uniqueStore = (label: string) => 'https://' + label + '-' + randomUUID() + '.example.se'
// An ACTIVE row must carry both activation signals (CHECK, 20260907143000):
// stored credentials and the session-bound browser confirmation.
const ACTIVE_COLUMNS =
'(company_id, user_id, store_url, status, consumer_key_encrypted, consumer_secret_encrypted, browser_confirmed_at)'
const ACTIVE_VALUES = "'active', 'enc:k', 'enc:s', now()"
/**
* Covers migration 20260806170000_woocommerce_connections:
* 1. RLS: members insert and read their own company's connection,
* non-members see nothing and cannot insert for a foreign company.
* 2. Multiple ACTIVE connections per company (multi-store: the
* one-active-per-company index was dropped in 20260811073422).
* 3. One store actively connected to at most one company.
* 4. No DELETE policy: a member DELETE silently affects zero rows.
* Covers migration 20260907143000_woocommerce_activation_gate:
* 5. status = 'active' requires stored credentials AND browser_confirmed_at,
* on insert and on update, so the wc-auth callback (keys only) and the
* return leg (confirmation only) can each write their signal without
* either alone ever producing a syncable row.
* Covers migration 20260907150000_woocommerce_browser_confirmation_server_only:
* 6. browser_confirmed_at is server-only: an end-user session (JWT role
* authenticated) cannot insert or update it, even on its own company's
* row, so a colleague cannot supply the initiator's signal. Service-role
* and pool writes pass.
* 7. Activation is server-only: an end-user session cannot insert an
* 'active' row or move a row into 'active', even one that carries both
* signals (the TTL lives in the server's conditional update, which the
* CHECK alone does not enforce). Leaving 'active' stays member-writable.
*/
describe('woocommerce_connections RLS', () => {
it('a member can insert and read their company connection', async () => {
const { userId, companyId } = await seedCompany()
await withUserContext(userId, async (client) => {
const inserted = await client.query(
`INSERT INTO public.woocommerce_connections
(company_id, user_id, store_url, status, oauth_state)
VALUES ($1, $2, 'https://shop.example.se', 'pending', gen_random_uuid())
RETURNING id`,
[companyId, userId],
)
expect(inserted.rows).toHaveLength(1)
const read = await client.query(
`SELECT status, store_url FROM public.woocommerce_connections WHERE company_id = $1`,
[companyId],
)
expect(read.rows).toEqual([
{ status: 'pending', store_url: 'https://shop.example.se' },
])
})
})
it('a non-member sees nothing and cannot insert for a foreign company', async () => {
const { userId: ownerId, companyId } = await seedCompany()
await getPool().query(
`INSERT INTO public.woocommerce_connections ${ACTIVE_COLUMNS}
VALUES ($1, $2, $3, ${ACTIVE_VALUES})`,
[companyId, ownerId, uniqueStore('foreign')],
)
const { userId: outsiderId } = await seedCompany() // member of a DIFFERENT company
await withUserContext(outsiderId, async (client) => {
const read = await client.query(
`SELECT id FROM public.woocommerce_connections WHERE company_id = $1`,
[companyId],
)
expect(read.rows).toHaveLength(0)
await expect(
client.query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, 'https://intruder.example.se', 'pending')`,
[companyId, outsiderId],
),
).rejects.toThrow(/row-level security/i)
})
})
it('a company may hold several ACTIVE connections (multi-store)', async () => {
const { userId, companyId } = await seedCompany()
await getPool().query(
`INSERT INTO public.woocommerce_connections ${ACTIVE_COLUMNS}
VALUES ($1, $2, $3, ${ACTIVE_VALUES})`,
[companyId, userId, uniqueStore('store-one')],
)
const second = await getPool().query(
`INSERT INTO public.woocommerce_connections ${ACTIVE_COLUMNS}
VALUES ($1, $2, $3, ${ACTIVE_VALUES}) RETURNING id`,
[companyId, userId, uniqueStore('store-two')],
)
expect(second.rows).toHaveLength(1)
})
it('a store may be actively connected to at most one company', async () => {
const { userId: userA, companyId: companyA } = await seedCompany()
const { userId: userB, companyId: companyB } = await seedCompany()
const sharedUrl = uniqueStore('shared')
await getPool().query(
`INSERT INTO public.woocommerce_connections ${ACTIVE_COLUMNS}
VALUES ($1, $2, $3, ${ACTIVE_VALUES})`,
[companyA, userA, sharedUrl],
)
await expect(
getPool().query(
`INSERT INTO public.woocommerce_connections ${ACTIVE_COLUMNS}
VALUES ($1, $2, $3, ${ACTIVE_VALUES})`,
[companyB, userB, sharedUrl],
),
).rejects.toMatchObject({ code: '23505' })
// A revoked row for the same store is fine (history is kept).
const revoked = await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status)
VALUES ($1, $2, $3, 'revoked') RETURNING id`,
[companyB, userB, sharedUrl],
)
expect(revoked.rows).toHaveLength(1)
})
it('members cannot DELETE (no DELETE policy; revoke is a status flip)', async () => {
const { userId, companyId } = await seedCompany()
const { rows } = await getPool().query(
`INSERT INTO public.woocommerce_connections ${ACTIVE_COLUMNS}
VALUES ($1, $2, $3, ${ACTIVE_VALUES}) RETURNING id`,
[companyId, userId, uniqueStore('keep')],
)
await withUserContext(userId, async (client) => {
const del = await client.query(
`DELETE FROM public.woocommerce_connections WHERE id = $1`,
[rows[0].id],
)
expect(del.rowCount).toBe(0)
})
const still = await getPool().query(
`SELECT id FROM public.woocommerce_connections WHERE id = $1`,
[rows[0].id],
)
expect(still.rows).toHaveLength(1)
})
describe('activation gate (20260907143000)', () => {
const insertPending = async (companyId: string, userId: string, storeUrl: string) => {
const { rows } = await getPool().query(
`INSERT INTO public.woocommerce_connections (company_id, user_id, store_url, status, oauth_state)
VALUES ($1, $2, $3, 'pending', gen_random_uuid()) RETURNING id`,
[companyId, userId, storeUrl],
)
return rows[0].id as string
}
it('refuses an active row that has keys but no browser confirmation', async () => {
const { userId, companyId } = await seedCompany()
await expect(
getPool().query(
`INSERT INTO public.woocommerce_connections
(company_id, user_id, store_url, status, consumer_key_encrypted, consumer_secret_encrypted)
VALUES ($1, $2, $3, 'active', 'enc:k', 'enc:s')`,
[companyId, userId, uniqueStore('keys-only')],
),
).rejects.toMatchObject({ code: '23514' })
})
it('refuses an active row that has a browser confirmation but no keys', async () => {
const { userId, companyId } = await seedCompany()
await expect(
getPool().query(
`INSERT INTO public.woocommerce_connections
(company_id, user_id, store_url, status, browser_confirmed_at)
VALUES ($1, $2, $3, 'active', now())`,
[companyId, userId, uniqueStore('browser-only')],
),
).rejects.toMatchObject({ code: '23514' })
})
it('lets each leg stage its own signal on a pending row, and the conditional flip activates exactly once', async () => {
const { userId, companyId } = await seedCompany()
const id = await insertPending(companyId, userId, uniqueStore('two-legs'))
// Callback leg: keys only. Still pending, so no consumer can select it.
await getPool().query(
`UPDATE public.woocommerce_connections
SET consumer_key_encrypted = 'enc:k', consumer_secret_encrypted = 'enc:s'
WHERE id = $1 AND status = 'pending'`,
[id],
)
// Flipping now must be refused by the CHECK: browser has not confirmed.
await expect(
getPool().query(
`UPDATE public.woocommerce_connections SET status = 'active' WHERE id = $1`,
[id],
),
).rejects.toMatchObject({ code: '23514' })
// The application-side conditional flip (activateIfComplete) matches
// zero rows instead of erroring: the WHERE carries the same predicate.
const flip = () =>
getPool().query(
`UPDATE public.woocommerce_connections
SET status = 'active', oauth_state = NULL
WHERE id = $1 AND status = 'pending'
AND consumer_key_encrypted IS NOT NULL
AND consumer_secret_encrypted IS NOT NULL
AND browser_confirmed_at IS NOT NULL
AND created_at >= now() - interval '15 minutes'`,
[id],
)
expect((await flip()).rowCount).toBe(0)
// Return leg: confirmation. Now the flip succeeds, and only once.
await getPool().query(
`UPDATE public.woocommerce_connections SET browser_confirmed_at = now()
WHERE id = $1 AND status = 'pending'`,
[id],
)
expect((await flip()).rowCount).toBe(1)
expect((await flip()).rowCount).toBe(0)
const { rows } = await getPool().query(
`SELECT status, oauth_state FROM public.woocommerce_connections WHERE id = $1`,
[id],
)
expect(rows[0]).toEqual({ status: 'active', oauth_state: null })
})
it('never flips a pending row older than the handshake TTL, even with both signals present', async () => {
const { userId, companyId } = await seedCompany()
const id = await insertPending(companyId, userId, uniqueStore('stale'))
// Initiator confirmed early, keys landed hours later.
await getPool().query(
`UPDATE public.woocommerce_connections
SET browser_confirmed_at = now(),
consumer_key_encrypted = 'enc:k', consumer_secret_encrypted = 'enc:s',
created_at = now() - interval '20 minutes'
WHERE id = $1`,
[id],
)
const flip = await getPool().query(
`UPDATE public.woocommerce_connections
SET status = 'active', oauth_state = NULL
WHERE id = $1 AND status = 'pending'
AND consumer_key_encrypted IS NOT NULL
AND consumer_secret_encrypted IS NOT NULL
AND browser_confirmed_at IS NOT NULL
AND created_at >= now() - interval '15 minutes'`,
[id],
)
expect(flip.rowCount).toBe(0)
const { rows } = await getPool().query(
`SELECT status FROM public.woocommerce_connections WHERE id = $1`,
[id],
)
expect(rows[0].status).toBe('pending')
})
it('refuses browser_confirmed_at from an end-user session, on insert and on update', async () => {
const { userId, companyId } = await seedCompany()
const id = await insertPending(companyId, userId, uniqueStore('server-only'))
await getPool().query(
`UPDATE public.woocommerce_connections
SET consumer_key_encrypted = 'enc:k', consumer_secret_encrypted = 'enc:s'
WHERE id = $1`,
[id],
)
// One user block per statement: withUserContext is a single transaction
// and the first refusal aborts it.
await withUserContext(userId, async (client) => {
// A member of the company (RLS passes) still cannot supply the signal.
await expect(
client.query(
`UPDATE public.woocommerce_connections SET browser_confirmed_at = now() WHERE id = $1`,
[id],
),
).rejects.toMatchObject({ code: '42501' })
})
await withUserContext(userId, async (client) => {
await expect(
client.query(
`INSERT INTO public.woocommerce_connections
(company_id, user_id, store_url, status, browser_confirmed_at)
VALUES ($1, $2, $3, 'pending', now())`,
[companyId, userId, uniqueStore('server-only-insert')],
),
).rejects.toMatchObject({ code: '42501' })
})
await withUserContext(userId, async (client) => {
// Other columns stay member-writable (the disconnect/supersede paths).
const ok = await client.query(
`UPDATE public.woocommerce_connections SET error_message = 'x' WHERE id = $1`,
[id],
)
expect(ok.rowCount).toBe(1)
})
// The server path (no end-user JWT) can.
const server = await getPool().query(
`UPDATE public.woocommerce_connections SET browser_confirmed_at = now()
WHERE id = $1 RETURNING browser_confirmed_at`,
[id],
)
expect(server.rows[0].browser_confirmed_at).not.toBeNull()
})
it('refuses an end-user session moving a fully staged row into active, but lets it leave active', async () => {
const { userId, companyId } = await seedCompany()
const id = await insertPending(companyId, userId, uniqueStore('user-activate'))
// Both signals present, staged by the server, but already past the TTL:
// the CHECK would pass, the server's conditional flip would not.
await getPool().query(
`UPDATE public.woocommerce_connections
SET consumer_key_encrypted = 'enc:k', consumer_secret_encrypted = 'enc:s',
browser_confirmed_at = now(), created_at = now() - interval '20 minutes'
WHERE id = $1`,
[id],
)
await withUserContext(userId, async (client) => {
await expect(
client.query(
`UPDATE public.woocommerce_connections SET status = 'active' WHERE id = $1`,
[id],
),
).rejects.toMatchObject({ code: '42501' })
})
await withUserContext(userId, async (client) => {
await expect(
client.query(
`INSERT INTO public.woocommerce_connections
(company_id, user_id, store_url, status, consumer_key_encrypted, consumer_secret_encrypted)
VALUES ($1, $2, $3, 'active', 'enc:k', 'enc:s')`,
[companyId, userId, uniqueStore('user-activate-insert')],
),
).rejects.toMatchObject({ code: '42501' })
})
// The server activates; the member may then disconnect (leave active).
await getPool().query(
`UPDATE public.woocommerce_connections SET status = 'active', created_at = now() WHERE id = $1`,
[id],
)
await withUserContext(userId, async (client) => {
const revoked = await client.query(
`UPDATE public.woocommerce_connections
SET status = 'revoked', consumer_key_encrypted = NULL, consumer_secret_encrypted = NULL
WHERE id = $1`,
[id],
)
expect(revoked.rowCount).toBe(1)
})
})
it('lets a parked row drop its keys: the CHECK only constrains active rows', async () => {
const { userId, companyId } = await seedCompany()
const id = await insertPending(companyId, userId, uniqueStore('parked'))
const parked = await getPool().query(
`UPDATE public.woocommerce_connections
SET status = 'error', oauth_state = NULL,
consumer_key_encrypted = NULL, consumer_secret_encrypted = NULL
WHERE id = $1 RETURNING status`,
[id],
)
expect(parked.rows[0].status).toBe('error')
})
})
})