Files
accounted/extensions/general/mcp-server/__tests__/list-customers.test.ts
T
13b69a2056 fix(customers): personnummer via MCP lands in personal_number, masked everywhere; MCP payment terms follow settings (#1788)
* fix(customers): personnummer on the MCP path lands in personal_number, masked everywhere; MCP payment terms follow settings

Follow-up to #1724 (Discord kalletoxic): the fix reached the web form and
the v1 REST API, but not the MCP path, and the web customer list still
showed a personnummer raw when it sat in org_number.

Personnummer (MCP + every write path):
- gnubok_create_customer gets a personal_number input. Until now it had
  none, so an agent creating a private person either dropped the number
  or put it in org_number, which nothing masks. Encrypted at staging
  (personal_number_encrypted + personal_number_masked; personal_number is
  now a forbidden staging key in staging-pii-guard), the approval preview
  shows ********-1234, commitCreateCustomer stores the ciphertext as-is.
  Idempotency hashes the masked preview (new StageOptions.idempotencyParams)
  because the random-IV ciphertext would make identical retries look like
  payload changes.
- A personnummer-shaped org_number on customer_type=individual is the
  personnummer in the wrong field: it is moved into personal_number
  (encrypted) and org_number cleared, on CreateCustomerSchema (web POST,
  v1 POST, v1 bulk), both PATCH routes, MCP staging, and commitCreateCustomer
  for in-flight ops. Only a DIFFERENT personnummer next to personal_number
  is refused (new CUSTOMER_PERSONAL_NUMBER_CONFLICT). The business-type
  guard from #1724 is unchanged and now also fires at MCP staging, so the
  user never approves an operation that fails at commit.
- Read side: the web customer list and gnubok_list_customers mask a legacy
  individual row's org_number personnummer instead of showing it raw;
  list_customers exposes personal_number_masked and never the ciphertext.
- scripts/repair-customer-personal-number-in-org-number.ts moves the
  existing rows (dry run: 134 rows across 10 companies on prod); run by
  hand with --confirm after deploy.
- customer-onboarding skill: EF customers follow the #1724 decision
  (individual + personal_number); ROT/RUT section names the real field.

Payment terms (MCP):
- gnubok_create_customer staged `payment_terms || 30`, so
  resolveDefaultPaymentTerms at commit always saw 30 and the company's
  invoice_default_days never reached MCP customers. Resolved at staging
  now, so the preview shows the value the row will get.

tools/list payload ceiling 59.75K to 59.85K (descriptions trimmed first,
rationale in payload-size.bench.test.ts). apiskill regenerated; no
migrations.

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

* fix(scripts): literal update payloads in the personnummer repair script

The no-phantom-columns scanner counts a runtime-built update payload as
unresolvable and the ceiling (379) had no headroom; two literal payloads
keep the guard able to resolve both branches.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:32:17 +02:00

45 lines
2.3 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
import { tools } from '../server'
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_list_customers')!
// Synthetic personnummer, never a real one.
const PERSONAL_NUMBER = '19900101-1234'
const MASKED = '********-1234'
describe('gnubok_list_customers: individual identifiers', () => {
it('never lists a personnummer raw: ciphertext is masked, a legacy org_number personnummer is masked and nulled', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{ id: 'c-1', name: 'Acme AB', customer_type: 'swedish_business', org_number: '556677-8899', personal_number: null },
{ id: 'c-2', name: 'Anna', customer_type: 'individual', org_number: null, personal_number: encryptPersonnummer(PERSONAL_NUMBER) },
// Written before the write paths moved an individual's personnummer
// out of org_number (the 2026-08-21 fix); the repair script moves it.
{ id: 'c-3', name: 'Bertil', customer_type: 'individual', org_number: PERSONAL_NUMBER, personal_number: null },
{ id: 'c-4', name: 'Cecilia', customer_type: 'individual', org_number: null, personal_number: null },
],
})
const result = (await tool().execute({}, 'company-1', 'user-1', supabase as never)) as {
customers: Array<Record<string, unknown>>
count: number
}
expect(result.count).toBe(4)
const byName = Object.fromEntries(result.customers.map((c) => [c.name as string, c]))
expect(byName['Acme AB']).toMatchObject({ org_number: '556677-8899' })
expect(byName['Acme AB']).not.toHaveProperty('personal_number')
expect(byName['Acme AB']).not.toHaveProperty('personal_number_masked')
expect(byName['Anna']).toMatchObject({ org_number: null, personal_number_masked: MASKED })
expect(byName['Bertil']).toMatchObject({ org_number: null, personal_number_masked: MASKED })
expect(byName['Cecilia']).toMatchObject({ org_number: null, personal_number_masked: null })
// Neither the plaintext nor the ciphertext leaves the tool.
const serialized = JSON.stringify(result)
expect(serialized).not.toContain(PERSONAL_NUMBER)
expect(serialized).not.toMatch(/"personal_number"/)
})
})