* feat(woocommerce): store order/refund feed extension Connect a WooCommerce store via the wc-auth key handshake (manual key fallback) with per-store consumer key/secret AES-256-GCM encrypted at rest, and import paid orders and refunds into the transactions inbox as a bank-style feed on the 1680 cash account. Feed-only: nothing auto-books, gateway fees/payouts are out of scope (core wc/v3 does not expose them). Sync is cursor-paginated on modified_after (offset pages only inside same-second date_modified ties), terminates on an empty page, holds the cursor below failed refund fetches / ingest errors / deadline-skipped work, checks the time budget between refund fetches, and drops rows dated on or before bookkeeping_locked_through on every run. Nightly cron gated on the extension registry + new paid capability woocommerce_sync (backfilled to existing bank_sync grant holders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): move woocommerce migrations past main's 20260806090000 origin/main gained 20260806090000_recurring_schedule_interval_months while this branch was in flight; identical version timestamps abort the Supabase apply, so the two new migrations move to 20260806170000/20260806170100. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit review findings - callback 503s early when WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY is unset: encryptCredential would otherwise throw after the probe and strand the pending row without error_message - disconnect and upstream-revoke clear the encrypted consumer key/secret: nothing reads them after revoke and keeping decryptable dead credentials is unnecessary retention - manual sync gets a 240s time budget and the panel reports a truncated run as 'partial, sync again' instead of a normal completion - listOrderRefunds terminates on an empty batch (hosts may cap per_page), dedupes by id against hosts that ignore page, and caps total pages - unparseable money strings count as errors and log instead of being silently identical to a zero total - pg test uses per-run unique store URLs so committed rows cannot hit the store_url partial unique index across pg-real runs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(woocommerce): resolve CodeRabbit cycle-2 findings - listOrderRefunds throws when the page cap is exhausted with data still flowing, instead of returning a silently partial list the sync cursor would advance past; the error routes into the existing held-cursor refund-retry path - partial sync results keep the row-error count, and the partial toast string surfaces it (ICU plural, hidden at zero) in both locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI after dropped push event Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
70 lines
2.9 KiB
TypeScript
70 lines
2.9 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import {
|
|
encryptCredential,
|
|
decryptCredential,
|
|
isWooCommerceConfigured,
|
|
} from '../lib/credentials'
|
|
import { normalizeStoreUrl } from '../lib/api-client'
|
|
|
|
describe('credential codec', () => {
|
|
beforeEach(() => {
|
|
process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY = 'test-key'
|
|
})
|
|
|
|
it('round-trips a consumer key', () => {
|
|
const ciphertext = encryptCredential('ck_1234567890abcdef')
|
|
expect(ciphertext).not.toContain('ck_1234567890abcdef')
|
|
expect(decryptCredential(ciphertext)).toBe('ck_1234567890abcdef')
|
|
})
|
|
|
|
it('produces a fresh IV per encryption (no ciphertext reuse)', () => {
|
|
expect(encryptCredential('cs_secret')).not.toBe(encryptCredential('cs_secret'))
|
|
})
|
|
|
|
it('rejects tampered ciphertext (GCM auth tag)', () => {
|
|
const ciphertext = encryptCredential('cs_secret')
|
|
const tampered = ciphertext.slice(0, -2) + (ciphertext.endsWith('AA') ? 'BB' : 'AA')
|
|
expect(() => decryptCredential(tampered)).toThrow()
|
|
})
|
|
|
|
it('requires the env key', () => {
|
|
delete process.env.WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY
|
|
expect(isWooCommerceConfigured()).toBe(false)
|
|
expect(() => encryptCredential('x')).toThrow(/WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY/)
|
|
})
|
|
})
|
|
|
|
describe('normalizeStoreUrl', () => {
|
|
it('normalizes bare domains, case, and trailing slashes', () => {
|
|
expect(normalizeStoreUrl('MinButik.se')).toBe('https://minbutik.se')
|
|
expect(normalizeStoreUrl('https://Shop.Example.se/')).toBe('https://shop.example.se')
|
|
expect(normalizeStoreUrl(' https://shop.example.se ')).toBe('https://shop.example.se')
|
|
})
|
|
|
|
it('keeps subdirectory installs', () => {
|
|
expect(normalizeStoreUrl('https://example.se/butik/')).toBe('https://example.se/butik')
|
|
})
|
|
|
|
it('refuses private and internal hosts (SSRF guard)', () => {
|
|
expect(normalizeStoreUrl('https://localhost')).toBeNull()
|
|
expect(normalizeStoreUrl('https://foo.localhost')).toBeNull()
|
|
expect(normalizeStoreUrl('https://intranet.local')).toBeNull()
|
|
expect(normalizeStoreUrl('https://db.internal')).toBeNull()
|
|
expect(normalizeStoreUrl('https://127.0.0.1')).toBeNull()
|
|
expect(normalizeStoreUrl('https://10.0.0.5')).toBeNull()
|
|
expect(normalizeStoreUrl('https://172.20.1.1')).toBeNull()
|
|
expect(normalizeStoreUrl('https://192.168.1.10')).toBeNull()
|
|
expect(normalizeStoreUrl('https://169.254.169.254')).toBeNull()
|
|
expect(normalizeStoreUrl('https://[::1]')).toBeNull()
|
|
})
|
|
|
|
it('refuses http, credentials, queries and garbage', () => {
|
|
expect(normalizeStoreUrl('http://insecure.se')).toBeNull()
|
|
expect(normalizeStoreUrl('https://user:pass@shop.se')).toBeNull()
|
|
expect(normalizeStoreUrl('https://shop.se/?a=1')).toBeNull()
|
|
expect(normalizeStoreUrl('https://shop.se/#frag')).toBeNull()
|
|
expect(normalizeStoreUrl('not a url at all')).toBeNull()
|
|
expect(normalizeStoreUrl('')).toBeNull()
|
|
})
|
|
})
|